using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa; [Trait("Category", "Unit")] public sealed class EquipmentNodeWalkerTests { [Fact] public void Walk_EmptyContent_EmitsNothing() { var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], [])); rec.Children.ShouldBeEmpty(); } [Fact] public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder() { var content = new EquipmentNamespaceContent( Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")], Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")], Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")], Tags: []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name var warsaw = rec.Children.First(c => c.BrowseName == "warsaw"); warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]); warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]); } [Fact] public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid() { var uuid = Guid.NewGuid(); var eq = Eq("eq-1", "line-1", "oven-3"); eq.EquipmentUuid = uuid; eq.MachineCode = "MC-42"; eq.ZTag = null; eq.SAPID = null; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList(); props.ShouldContain("EquipmentId"); props.ShouldContain("EquipmentUuid"); props.ShouldContain("MachineCode"); props.ShouldNotContain("ZTag"); props.ShouldNotContain("SAPID"); equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString()); } [Fact] public void Walk_Adds_ZTag_And_SAPID_When_Present() { var eq = Eq("eq-1", "line-1", "oven-3"); eq.ZTag = "ZT-0042"; eq.SAPID = "10000042"; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042"); equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042"); } [Fact] public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent() { var eq = Eq("eq-1", "line-1", "oven-3"); eq.Manufacturer = "Trumpf"; eq.Model = "TruLaser-3030"; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification"); identification.ShouldNotBeNull(); identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer"); identification.Properties.Select(p => p.BrowseName).ShouldContain("Model"); } [Fact] public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull() { var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification"); } [Fact] public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment() { var eq = Eq("eq-1", "line-1", "oven-3"); var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1"); var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1"); var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag1, tag2, unboundTag]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; equipmentNode.Variables.Count.ShouldBe(2); equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]); equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01"); equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); } [Fact] public void Walk_FallsBack_To_String_For_Unparseable_DataType() { var eq = Eq("eq-1", "line-1", "oven-3"); var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1"); var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var variable = rec.Children[0].Children[0].Children[0].Variables.Single(); variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String); } [Fact] public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator() { var eq = Eq("eq-1", "line-1", "oven-3"); var vtag = new VirtualTag { VirtualTagRowId = Guid.NewGuid(), GenerationId = 1, VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate", DataType = "Float32", ScriptId = "scr-1", Historize = true, }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [], VirtualTags: [vtag]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var equipmentNode = rec.Children[0].Children[0].Children[0]; var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate"); v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual); v.AttributeInfo.VirtualTagId.ShouldBe("vt-1"); v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); v.AttributeInfo.IsHistorized.ShouldBeTrue(); v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32); } [Fact] public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm() { var eq = Eq("eq-1", "line-1", "oven-3"); var alarm = new ScriptedAlarm { ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1, ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp", AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded", PredicateScriptId = "scr-9", Severity = 800, }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [], ScriptedAlarms: [alarm]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp"); v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm); v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1"); v.AttributeInfo.VirtualTagId.ShouldBeNull(); v.AttributeInfo.IsAlarm.ShouldBeTrue(); v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean); } [Fact] public void Walk_Skips_Disabled_VirtualTags_And_Alarms() { var eq = Eq("eq-1", "line-1", "oven-3"); var vtag = new VirtualTag { VirtualTagRowId = Guid.NewGuid(), GenerationId = 1, VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled", DataType = "Float32", ScriptId = "scr-1", Enabled = false, }; var alarm = new ScriptedAlarm { ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1, ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm", AlarmType = "LimitAlarm", MessageTemplate = "x", PredicateScriptId = "scr-9", Enabled = false, }; var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty(); } [Fact] public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe() { // Backwards-compat — callers that don't populate the new collections still work. var eq = Eq("eq-1", "line-1", "oven-3"); var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); // must not throw rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty(); } [Fact] public void Driver_tag_default_NodeSourceKind_is_Driver() { var eq = Eq("eq-1", "line-1", "oven-3"); var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1"); var content = new EquipmentNamespaceContent( [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]); var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, content); var v = rec.Children[0].Children[0].Children[0].Variables.Single(); v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver); v.AttributeInfo.VirtualTagId.ShouldBeNull(); v.AttributeInfo.ScriptedAlarmId.ShouldBeNull(); } // ----- builders for test seed rows ----- private static UnsArea Area(string id, string name) => new() { UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1, }; private static UnsLine Line(string id, string areaId, string name) => new() { UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1, }; private static Equipment Eq(string equipmentId, string lineId, string name) => new() { EquipmentRowId = Guid.NewGuid(), GenerationId = 1, EquipmentId = equipmentId, EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv", UnsLineId = lineId, Name = name, MachineCode = "MC-" + name, }; private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new() { TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId, DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name, DataType = dataType, AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite, TagConfig = address, }; // ----- recording IAddressSpaceBuilder ----- private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder { public string BrowseName { get; } = browseName; public List Children { get; } = new(); public List Variables { get; } = new(); public List Properties { get; } = new(); public IAddressSpaceBuilder Folder(string name, string _) { var child = new RecordingBuilder(name); Children.Add(child); return child; } public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr) { var v = new RecordingVariable(name, attr); Variables.Add(v); return v; } public void AddProperty(string name, DriverDataType _, object? value) => Properties.Add(new RecordingProperty(name, value)); } private sealed record RecordingProperty(string BrowseName, object? Value); private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle { public string FullReference => AttributeInfo.FullName; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); } }