using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Types.Enums; using ScadaLink.TemplateEngine.Flattening; namespace ScadaLink.TemplateEngine.Tests.Flattening; public class FlatteningServiceTests { private readonly FlatteningService _sut = new(); private static Instance CreateInstance(string name = "TestInstance", int templateId = 1, int siteId = 1) => new(name) { Id = 1, TemplateId = templateId, SiteId = siteId }; private static Template CreateTemplate(int id, string name, int? parentId = null) { var t = new Template(name) { Id = id, ParentTemplateId = parentId }; return t; } [Fact] public void Flatten_EmptyTemplateChain_ReturnsFailure() { var instance = CreateInstance(); var result = _sut.Flatten( instance, [], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsFailure); Assert.Contains("empty", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact] public void Flatten_SingleTemplate_ResolvesAttributes() { var template = CreateTemplate(1, "Base"); template.Attributes.Add(new TemplateAttribute("Temperature") { DataType = DataType.Double, Value = "25.0" }); template.Attributes.Add(new TemplateAttribute("Status") { DataType = DataType.String, Value = "OK" }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); Assert.Equal(2, result.Value.Attributes.Count); Assert.Equal("Temperature", result.Value.Attributes[1].CanonicalName); // Sorted Assert.Equal("25.0", result.Value.Attributes[1].Value); Assert.Equal("Status", result.Value.Attributes[0].CanonicalName); } [Fact] public void Flatten_InheritanceChain_DerivedOverridesBase() { var baseTemplate = CreateTemplate(2, "Base"); baseTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "100.0" }); baseTemplate.Attributes.Add(new TemplateAttribute("BaseOnly") { DataType = DataType.String, Value = "base" }); var childTemplate = CreateTemplate(1, "Child", parentId: 2); childTemplate.Attributes.Add(new TemplateAttribute("Speed") { DataType = DataType.Double, Value = "200.0" }); childTemplate.Attributes.Add(new TemplateAttribute("ChildOnly") { DataType = DataType.Int32, Value = "42" }); // Chain: [child, base] — most-derived first var instance = CreateInstance(); var result = _sut.Flatten( instance, [childTemplate, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); Assert.Equal(3, result.Value.Attributes.Count); var speed = result.Value.Attributes.First(a => a.CanonicalName == "Speed"); Assert.Equal("200.0", speed.Value); // Child's value wins } [Fact] public void Flatten_LockedAttribute_NotOverriddenByDerived() { var baseTemplate = CreateTemplate(2, "Base"); baseTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "locked", IsLocked = true }); var childTemplate = CreateTemplate(1, "Child", parentId: 2); childTemplate.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "overridden" }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [childTemplate, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var locked = result.Value.Attributes.First(a => a.CanonicalName == "Locked"); Assert.Equal("locked", locked.Value); // Base locked value preserved } [Fact] public void Flatten_InstanceOverride_AppliedToUnlockedAttribute() { var template = CreateTemplate(1, "Base"); template.Attributes.Add(new TemplateAttribute("Threshold") { DataType = DataType.Double, Value = "50.0" }); var instance = CreateInstance(); instance.AttributeOverrides.Add(new InstanceAttributeOverride("Threshold") { OverrideValue = "75.0" }); var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var attr = result.Value.Attributes.First(a => a.CanonicalName == "Threshold"); Assert.Equal("75.0", attr.Value); Assert.Equal("Override", attr.Source); } [Fact] public void Flatten_InstanceOverride_SkippedForLockedAttribute() { var template = CreateTemplate(1, "Base"); template.Attributes.Add(new TemplateAttribute("Locked") { DataType = DataType.String, Value = "original", IsLocked = true }); var instance = CreateInstance(); instance.AttributeOverrides.Add(new InstanceAttributeOverride("Locked") { OverrideValue = "changed" }); var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var attr = result.Value.Attributes.First(a => a.CanonicalName == "Locked"); Assert.Equal("original", attr.Value); // Lock honored } [Fact] public void Flatten_ComposedModule_PathQualifiedNames() { var composedTemplate = CreateTemplate(2, "Pump"); composedTemplate.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" }); composedTemplate.Scripts.Add(new TemplateScript("Start", "// start") { Id = 10 }); var parentTemplate = CreateTemplate(1, "Station"); parentTemplate.Attributes.Add(new TemplateAttribute("StationName") { DataType = DataType.String, Value = "S1" }); var compositions = new Dictionary> { [1] = new List { new("MainPump") { ComposedTemplateId = 2 } } }; var composedChains = new Dictionary> { [2] = [composedTemplate] }; var instance = CreateInstance(); var result = _sut.Flatten( instance, [parentTemplate], compositions, composedChains, new Dictionary()); Assert.True(result.IsSuccess); Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "MainPump.RPM"); Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "StationName"); Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "MainPump.Start"); } [Fact] public void Flatten_ConnectionBindings_ResolvedCorrectly() { var template = CreateTemplate(1, "Base"); template.Attributes.Add(new TemplateAttribute("Temp") { DataType = DataType.Double, DataSourceReference = "ns=2;s=Temperature" }); var instance = CreateInstance(); instance.ConnectionBindings.Add(new InstanceConnectionBinding("Temp") { DataConnectionId = 100 }); var connections = new Dictionary { [100] = new("OPC-Server1", "OpcUa", 1) { Id = 100, PrimaryConfiguration = "opc.tcp://localhost:4840" } }; var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), connections); Assert.True(result.IsSuccess); var attr = result.Value.Attributes.First(a => a.CanonicalName == "Temp"); Assert.Equal(100, attr.BoundDataConnectionId); Assert.Equal("OPC-Server1", attr.BoundDataConnectionName); Assert.Equal("OpcUa", attr.BoundDataConnectionProtocol); } [Fact] public void Flatten_Alarms_ResolvedFromChain() { var template = CreateTemplate(1, "Base"); template.Alarms.Add(new TemplateAlarm("HighTemp") { TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}", PriorityLevel = 1 }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); Assert.Single(result.Value.Alarms); Assert.Equal("HighTemp", result.Value.Alarms[0].CanonicalName); Assert.Equal("RangeViolation", result.Value.Alarms[0].TriggerType); } [Fact] public void Flatten_InstanceMetadata_SetCorrectly() { var template = CreateTemplate(1, "Base"); var instance = CreateInstance("MyInstance", templateId: 1, siteId: 5); instance.AreaId = 3; var result = _sut.Flatten( instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); Assert.Equal("MyInstance", result.Value.InstanceUniqueName); Assert.Equal(1, result.Value.TemplateId); Assert.Equal(5, result.Value.SiteId); Assert.Equal(3, result.Value.AreaId); } [Fact] public void Flatten_InheritedAttributeOnDerived_BaseValueWins() { var baseTemplate = CreateTemplate(2, "Sensor"); baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" }); var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); derived.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "STALE", IsInherited = true }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [derived, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint"); Assert.Equal("100.0", setPoint.Value); } [Fact] public void Flatten_OverriddenAttributeOnDerived_DerivedValueWins() { var baseTemplate = CreateTemplate(2, "Sensor"); baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0" }); var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); derived.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "150.0", IsInherited = false }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [derived, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var setPoint = result.Value.Attributes.First(a => a.CanonicalName == "SetPoint"); Assert.Equal("150.0", setPoint.Value); } [Fact] public void Flatten_LockedInDerivedOverride_Fails() { var baseTemplate = CreateTemplate(2, "Sensor"); baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "100.0", LockedInDerived = true }); var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); derived.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Double, Value = "150.0", IsInherited = false }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [derived, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsFailure); Assert.Contains("LockedInDerived", result.Error); Assert.Contains("SetPoint", result.Error); } [Fact] public void Flatten_InheritedScriptOnDerived_BaseCodeWins() { var baseTemplate = CreateTemplate(2, "Sensor"); baseTemplate.Scripts.Add(new TemplateScript("Sample", "return base;")); var derived = CreateTemplate(1, "Pump.TempSensor", parentId: 2); derived.Scripts.Add(new TemplateScript("Sample", "stale code") { IsInherited = true }); var instance = CreateInstance(); var result = _sut.Flatten( instance, [derived, baseTemplate], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var script = result.Value.Scripts.First(s => s.CanonicalName == "Sample"); Assert.Equal("return base;", script.Code); } // ── TemplateEngine-001: deep composition nesting ─────────────────────── [Fact] public void Flatten_ThreeLevelComposition_AttributesAlarmsScriptsAllResolved() { // Station composes Pump (level 1); Pump composes Motor (level 2); // Motor composes Bearing (level 3). var bearing = CreateTemplate(4, "Bearing"); bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" }); bearing.Alarms.Add(new TemplateAlarm("HighVibration") { TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}", PriorityLevel = 1 }); bearing.Scripts.Add(new TemplateScript("MonitorBearing", "// monitor") { Id = 40 }); var motor = CreateTemplate(3, "Motor"); motor.Attributes.Add(new TemplateAttribute("Current") { DataType = DataType.Double, Value = "10" }); var pump = CreateTemplate(2, "Pump"); pump.Attributes.Add(new TemplateAttribute("RPM") { DataType = DataType.Double, Value = "1500" }); var station = CreateTemplate(1, "Station"); var compositions = new Dictionary> { [1] = new List { new("MainPump") { ComposedTemplateId = 2 } }, [2] = new List { new("DriveMotor") { ComposedTemplateId = 3 } }, [3] = new List { new("FrontBearing") { ComposedTemplateId = 4 } }, }; var composedChains = new Dictionary> { [2] = [pump], [3] = [motor], [4] = [bearing], }; var instance = CreateInstance(); var result = _sut.Flatten(instance, [station], compositions, composedChains, new Dictionary()); Assert.True(result.IsSuccess); // Level 3 attribute must be present with the full path-qualified name. Assert.Contains(result.Value.Attributes, a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.Vibration"); // Level 3 alarm must be present (was dropped entirely before). Assert.Contains(result.Value.Alarms, a => a.CanonicalName == "MainPump.DriveMotor.FrontBearing.HighVibration"); // Level 3 script must be present (was dropped entirely before). Assert.Contains(result.Value.Scripts, s => s.CanonicalName == "MainPump.DriveMotor.FrontBearing.MonitorBearing"); } [Fact] public void Flatten_NestedComposedAlarm_TriggerAttributePrefixed() { var bearing = CreateTemplate(4, "Bearing"); bearing.Attributes.Add(new TemplateAttribute("Vibration") { DataType = DataType.Double, Value = "0.1" }); bearing.Alarms.Add(new TemplateAlarm("HighVibration") { TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"attributeName\":\"Vibration\",\"high\":5}", PriorityLevel = 1 }); var motor = CreateTemplate(3, "Motor"); var pump = CreateTemplate(2, "Pump"); var station = CreateTemplate(1, "Station"); var compositions = new Dictionary> { [1] = new List { new("MainPump") { ComposedTemplateId = 2 } }, [2] = new List { new("DriveMotor") { ComposedTemplateId = 3 } }, [3] = new List { new("FrontBearing") { ComposedTemplateId = 4 } }, }; var composedChains = new Dictionary> { [2] = [pump], [3] = [motor], [4] = [bearing], }; var instance = CreateInstance(); var result = _sut.Flatten(instance, [station], compositions, composedChains, new Dictionary()); Assert.True(result.IsSuccess); var alarm = result.Value.Alarms.First(a => a.CanonicalName.EndsWith("HighVibration")); // The trigger's attribute reference must carry the full nested prefix. Assert.Contains("MainPump.DriveMotor.FrontBearing.Vibration", alarm.TriggerConfiguration); } // ── TemplateEngine-004: alarm on-trigger script resolution ───────────── [Fact] public void Flatten_AlarmOnTriggerScript_ResolvedToCanonicalName() { var template = CreateTemplate(1, "Base"); template.Scripts.Add(new TemplateScript("HandleAlarm", "// handle") { Id = 50 }); template.Alarms.Add(new TemplateAlarm("HighTemp") { TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}", PriorityLevel = 1, OnTriggerScriptId = 50 }); var instance = CreateInstance(); var result = _sut.Flatten(instance, [template], new Dictionary>(), new Dictionary>(), new Dictionary()); Assert.True(result.IsSuccess); var alarm = result.Value.Alarms.First(a => a.CanonicalName == "HighTemp"); Assert.Equal("HandleAlarm", alarm.OnTriggerScriptCanonicalName); } [Fact] public void Flatten_ComposedAlarmOnTriggerScript_ResolvedWithPrefix() { var composedTemplate = CreateTemplate(2, "Pump"); composedTemplate.Scripts.Add(new TemplateScript("PumpAlarmHandler", "// h") { Id = 60 }); composedTemplate.Alarms.Add(new TemplateAlarm("PumpFault") { TriggerType = AlarmTriggerType.ValueMatch, TriggerConfiguration = "{\"attributeName\":\"State\",\"value\":\"FAULT\"}", PriorityLevel = 5, OnTriggerScriptId = 60 }); var station = CreateTemplate(1, "Station"); var compositions = new Dictionary> { [1] = new List { new("MainPump") { ComposedTemplateId = 2 } } }; var composedChains = new Dictionary> { [2] = [composedTemplate] }; var instance = CreateInstance(); var result = _sut.Flatten(instance, [station], compositions, composedChains, new Dictionary()); Assert.True(result.IsSuccess); var alarm = result.Value.Alarms.First(a => a.CanonicalName == "MainPump.PumpFault"); Assert.Equal("MainPump.PumpAlarmHandler", alarm.OnTriggerScriptCanonicalName); } }