using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation; public class SemanticValidatorTests { private readonly SemanticValidator _sut = new(); [Fact] public void Validate_NativeAlarmSource_UnknownConnection_ReturnsError() { var cfg = new FlattenedConfiguration { InstanceUniqueName = "Instance1", NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "Ghost", SourceReference = "x" }] }; var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet { "RealConn" }); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } [Fact] public void Validate_NativeAlarmSource_EmptySourceRef_ReturnsError() { var cfg = new FlattenedConfiguration { InstanceUniqueName = "Instance1", NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "" }] }; var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet { "RealConn" }); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } [Fact] public void Validate_NativeAlarmSource_ValidBinding_NoError() { var cfg = new FlattenedConfiguration { InstanceUniqueName = "Instance1", NativeAlarmSources = [new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "ns=2;s=T1" }] }; var result = _sut.Validate(cfg, alarmCapableConnectionNames: new HashSet { "RealConn" }); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); } [Fact] public void Validate_CallScriptTargetNotFound_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"NonExistent\");" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound && e.Message.Contains("NonExistent")); } [Fact] public void Validate_CallScriptTargetExists_NoError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } [Fact] public void Validate_CallSharedTargetNotFound_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Caller", Code = "CallShared(\"MissingShared\");" } ] }; var result = _sut.Validate(config, sharedScripts: []); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound && e.Message.Contains("MissingShared")); } [Fact] public void Validate_CallSharedTargetExists_NoError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Caller", Code = "CallShared(\"Utility\");" } ] }; var shared = new List { new() { CanonicalName = "Utility", Code = "// shared" } }; var result = _sut.Validate(config, shared); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.CallTargetNotFound); } [Fact] public void Validate_ParameterCountMismatch_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 42);" // 1 arg but 2 expected } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } // ── #21 Argument-type validation ──────────────────────────────────────── [Fact] public void Validate_ArgumentTypeMismatch_StringForInteger_ReturnsError() { // Target expects (Integer a); caller passes a string literal. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Int32\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", \"hello\");" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch && e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Validate_ArgumentTypeMismatch_NumberForString_ReturnsError() { // Target expects (String a); caller passes an integer literal. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 42);" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch && e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Validate_ArgumentTypeMismatch_BooleanForInteger_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", true);" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch && e.Message.Contains("type", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Validate_ArgumentTypeMatch_CorrectLiterals_NoError() { // (Integer a, String b, Boolean c) called with matching literals. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"},{\"name\":\"b\",\"type\":\"String\"},{\"name\":\"c\",\"type\":\"Boolean\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 42, \"hi\", true);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_IntegerLiteralForFloat_NoError() { // Numeric widening: an integer literal is acceptable where a Float is declared. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Float\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 5);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_UnknownExpression_NoFalsePositive() { // The argument is a variable/expression whose type can't be statically // inferred — must NOT be flagged even though it could be anything. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "var v = Attributes[\"Temp\"].Value; CallScript(\"Target\", v);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_ObjectInitializerArgument_NoFalsePositive() { // Real-world call shape: a single anonymous-object argument. Object // initializers can't be mapped to positional primitive params — skip. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", new { a = 5 });" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_UntypedParameter_NoFalsePositive() { // Target declares an Object parameter — anything is assignable, no flag. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Object\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", \"anything\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_CompoundExpressionStartingWithLiteral_NoFalsePositive() { // `42 + offset` starts with an int literal but is a compound expression // of unknown type — must NOT be classified or flagged. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"String\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 42 + offset);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgumentType_ConcatenatedStringExpression_NoFalsePositive() { // `"a" + x` starts with a string literal but is a concatenation of // unknown overall type — be conservative, don't flag against Integer. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", \"a\" + x);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } // ── #20 Return-type validation ────────────────────────────────────────── [Fact] public void Validate_ReturnTypeMismatch_BooleanResultIntoInt_ReturnsError() { // Target returns Boolean; caller assigns it into a typed `int` local. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return true;", ReturnDefinition = "{\"type\":\"boolean\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "int x = CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnTypeMismatch_StringResultIntoBool_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return \"x\";", ReturnDefinition = "{\"type\":\"string\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "bool b = await CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnTypeMatch_CompatibleAssignment_NoError() { // Target returns Integer; caller assigns into an `int` local — compatible. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return 1;", ReturnDefinition = "{\"type\":\"integer\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "int x = CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnType_VarAssignment_NoFalsePositive() { // `var` LHS — caller's expected type can't be inferred, so no flag. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return \"x\";", ReturnDefinition = "{\"type\":\"string\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "var x = CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnType_UnusedResult_NoFalsePositive() { // Result isn't assigned anywhere — nothing to check. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return \"x\";", ReturnDefinition = "{\"type\":\"string\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnType_UndeclaredReturn_NoFalsePositive() { // Target has no ReturnDefinition — can't compare, so no flag. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return 1;" }, new ResolvedScript { CanonicalName = "Caller", Code = "string s = CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnTypeMismatch_QualifiedInstanceCall_ReturnsError() { // Real-code form: `Instance.CallScript(...)`. The receiver prefix must // be skipped so #20 still sees the typed-local assignment. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return \"x\";", ReturnDefinition = "{\"type\":\"string\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "bool b = await Instance.CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnTypeMismatch_QualifiedSharedCall_ReturnsError() { // Real-code form: `Scripts.CallShared(...)`. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Caller", Code = "int n = Scripts.CallShared(\"Util\");" } ] }; var shared = new List { new() { CanonicalName = "Util", Code = "return true;", ReturnDefinition = "{\"type\":\"boolean\"}" } }; var result = _sut.Validate(config, shared); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_ReturnType_CastExpression_NoFalsePositive() { // The result feeds a cast expression — not a clean typed-local // assignment, so the assigned type can't be inferred. No flag. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "return \"x\";", ReturnDefinition = "{\"type\":\"string\"}" }, new ResolvedScript { CanonicalName = "Caller", Code = "int x = (int)CallScript(\"Target\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ReturnTypeMismatch); } [Fact] public void Validate_RangeViolationOnNonNumeric_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Attributes = [ new ResolvedAttribute { CanonicalName = "Status", DataType = "String" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "BadAlarm", TriggerType = "RangeViolation", TriggerConfiguration = "{\"attributeName\":\"Status\"}" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } [Fact] public void Validate_RangeViolationOnNumeric_NoError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Attributes = [ new ResolvedAttribute { CanonicalName = "Temp", DataType = "Double" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", TriggerConfiguration = "{\"attributeName\":\"Temp\"}" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } [Fact] public void Validate_OnTriggerScriptNotFound_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [new ResolvedScript { CanonicalName = "OtherScript", Code = "var x = 1;" }], Alarms = [ new ResolvedAlarm { CanonicalName = "Alarm1", TriggerType = "ValueMatch", OnTriggerScriptCanonicalName = "MissingScript" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.OnTriggerScriptNotFound); } [Fact] public void Validate_InstanceScriptCallsAlarmOnTrigger_ReturnsError() { var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "AlarmHandler", Code = "// alarm handler" }, new ResolvedScript { CanonicalName = "RegularScript", Code = "CallScript(\"AlarmHandler\");" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "Alarm1", TriggerType = "ValueMatch", OnTriggerScriptCanonicalName = "AlarmHandler" } ] }; var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.CrossCallViolation); } [Fact] public void ExtractCallTargets_MultipleCallTypes() { var code = @" var x = CallScript(""Script1"", arg1, arg2); CallShared(""Shared1""); CallScript(""Script2""); "; var targets = SemanticValidator.ExtractCallTargets(code); Assert.Equal(3, targets.Count); Assert.Contains(targets, t => t.TargetName == "Script1" && !t.IsShared && t.ArgumentCount == 2); Assert.Contains(targets, t => t.TargetName == "Shared1" && t.IsShared && t.ArgumentCount == 0); Assert.Contains(targets, t => t.TargetName == "Script2" && !t.IsShared && t.ArgumentCount == 0); } [Fact] public void ParseParameterDefinitions_ValidJson_ReturnsList() { var json = "[{\"name\":\"a\",\"type\":\"Int32\"},{\"name\":\"b\",\"type\":\"String\"}]"; var result = SemanticValidator.ParseParameterDefinitions(json); Assert.Equal(2, result.Count); Assert.Equal("Int32", result[0]); Assert.Equal("String", result[1]); } [Fact] public void ParseParameterDefinitions_NullOrEmpty_ReturnsEmpty() { Assert.Empty(SemanticValidator.ParseParameterDefinitions(null)); Assert.Empty(SemanticValidator.ParseParameterDefinitions("")); } // ── HiLo validation ───────────────────────────────────────────────────── private static FlattenedConfiguration HiLoConfig(string attrName, string dataType, string triggerJson) => new() { InstanceUniqueName = "Instance1", Attributes = [new ResolvedAttribute { CanonicalName = attrName, DataType = dataType }], Alarms = [ new ResolvedAlarm { CanonicalName = "Hi/Lo Alarm", TriggerType = "HiLo", TriggerConfiguration = triggerJson } ] }; [Fact] public void Validate_HiLoOnNonNumericAttribute_ReturnsError() { var config = HiLoConfig("Status", "String", "{\"attributeName\":\"Status\",\"hi\":80}"); var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType && e.Message.Contains("HiLo") && e.Message.Contains("non-numeric")); } [Fact] public void Validate_HiLoOnNumericAttribute_NoOperandTypeError() { var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}"); var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } [Fact] public void Validate_HiLoNoSetpoints_ReturnsWarning() { // No setpoints means the alarm can never fire — design-time warning. var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\"}"); var result = _sut.Validate(config); Assert.Contains(result.Warnings, w => w.Category == ValidationCategory.TriggerOperandType && w.Message.Contains("no setpoints")); } [Fact] public void Validate_HiLoLoLoGreaterThanLo_ReturnsError() { var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"loLo\":20,\"lo\":10}"); var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType && e.Message.Contains("LoLo") && e.Message.Contains("Lo")); } [Fact] public void Validate_HiLoHiGreaterThanHiHi_ReturnsError() { var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"hi\":120,\"hiHi\":100}"); var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType && e.Message.Contains("Hi") && e.Message.Contains("HiHi")); } [Fact] public void Validate_HiLoLowSideOverlapsHighSide_ReturnsError() { // Lo (50) >= Hi (40) — bands overlap. var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"lo\":50,\"hi\":40}"); var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType && e.Message.Contains("overlap")); } [Fact] public void Validate_HiLoOnlyHighSideConfigured_NoOrderingError() { // Only Hi/HiHi configured — no low-side comparison needed. var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100}"); var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } [Fact] public void Validate_HiLoNegativeDeadband_ReturnsError() { var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":-1}"); var result = _sut.Validate(config); Assert.Contains(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType && e.Message.Contains("Hi deadband") && e.Message.Contains("non-negative")); } [Fact] public void Validate_HiLoZeroDeadband_NoError() { // Zero deadband is the default (no hysteresis) and must be accepted. var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":100,\"hiDeadband\":0,\"hiHiDeadband\":0}"); var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); } [Fact] public void Validate_HiLoValidOrdering_NoErrors() { // LoLo (-10) < Lo (0) < Hi (90) < HiHi (100) — fully valid. var config = HiLoConfig("Temp", "Double", "{\"attributeName\":\"Temp\",\"loLo\":-10,\"lo\":0,\"hi\":90,\"hiHi\":100}"); var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.TriggerOperandType); Assert.DoesNotContain(result.Warnings, w => w.Category == ValidationCategory.TriggerOperandType); } // ── M2.7 review nits — comment-aware arg tokenizer ───────────────────── [Fact] public void Validate_ArgSplit_LineCommentWithCommaInsideArgs_NoFalsePositive() { // A `//` line comment containing a comma must NOT be counted as an arg separator. // "Target" expects (a: Integer) — one real arg; the comment comma is noise. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", 42 /* , extra */);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } [Fact] public void Validate_ArgSplit_BlockCommentWithCommaInsideArgs_NoFalsePositive() { // A `/* */` block comment containing a comma must NOT be counted as an arg separator. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"},{\"name\":\"b\",\"type\":\"String\"}]" }, new ResolvedScript { CanonicalName = "Caller", // Two real args, but the block comment adds a spurious comma if tokenizer is not comment-aware. Code = "CallScript(\"Target\", 42 /* ,bogus */, \"hi\");" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } // ── M2.7 review nits — stricter numeric-literal inference ─────────────── [Fact] public void Validate_ArgumentType_UnderscoreLeadingIdentifier_NoFalsePositive() { // `_2` starts with an underscore — it is a C# identifier, not a numeric literal. // IsNumericLiteral must return false → type inferred as Unknown → no mismatch. var config = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Scripts = [ new ResolvedScript { CanonicalName = "Target", Code = "var x = 1;", ParameterDefinitions = "[{\"name\":\"a\",\"type\":\"Integer\"}]" }, new ResolvedScript { CanonicalName = "Caller", Code = "CallScript(\"Target\", _2);" } ] }; var result = _sut.Validate(config); Assert.DoesNotContain(result.Errors, e => e.Category == ValidationCategory.ParameterMismatch); } }