using ScadaLink.Commons.Types.Flattening; using ScadaLink.TemplateEngine.Validation; namespace ScadaLink.TemplateEngine.Tests.Validation; public class SemanticValidatorTests { private readonly SemanticValidator _sut = new(); [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); } [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); } }