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 { /// /// v3: raw tags no longer flow into and the walker /// emits no raw-tag variable nodes. Raw tags reach the UNS namespace via /// UnsTagReference as the dual-namespace fan-out of their backing raw nodes, which lands /// with the Batch-4 address space. Tests that asserted the old equipment-tag variable /// materialization are skipped with this reason until that projection is built. /// internal const string DarkBatch4 = "v3 dark address space: raw-tag → UNS variable materialization lands in Batch 4 " + "(UnsTagReference fan-out), not in EquipmentNodeWalker."; /// Verifies that walking empty content emits no nodes. [Fact] public void Walk_EmptyContent_EmitsNothing() { var rec = new RecordingBuilder("root"); EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [])); rec.Children.ShouldBeEmpty(); } /// Verifies that walking emits Area, Line, and Equipment folders in unsorted order. [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")]); 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"]); } /// Verifies that walking adds five identifier properties on equipment nodes, skipping null ZTag and SAPID. [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()); } /// Verifies that walking adds ZTag and SAPID properties when present. [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"); } /// Verifies that walking materializes an Identification subfolder when any identification field is present. [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"); } /// Verifies that walking omits the Identification subfolder when all identification fields are null. [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"); } /// /// Batch-4 pending: the walker must emit a raw-tag variable per UnsTagReference under /// equipment (dual-namespace fan-out). Preserved as a skipped placeholder — the pre-v3 test /// asserted equipment-tag variable materialization directly off an equipment-bound Tag, /// which no longer exists (tags bind to Devices, not equipment). Intent retained; unskip when /// the Batch-4 UNS projection lands. /// [Fact(Skip = DarkBatch4)] public void Walk_Emits_RawTag_Variables_Under_Equipment_DARK_Batch4() { // When Batch 4 lands, EquipmentNamespaceContent carries UnsTagReference rows and the walker // emits one Variable per referenced raw node (correct DriverDataType, address, source kind). } /// Verifies that walking emits virtual tag variables with Virtual source discriminator. [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(), 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); } /// Verifies that walking emits scripted alarm variables with ScriptedAlarm source and IsAlarm flag. [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(), 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); } /// Verifies that walking skips disabled virtual tags and alarms. [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(), VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled", DataType = "Float32", ScriptId = "scr-1", Enabled = false, }; var alarm = new ScriptedAlarm { ScriptedAlarmRowId = Guid.NewGuid(), 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(); } /// Verifies that walking with null virtual tags and scripted alarms is safe. [Fact] public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe() { // Backwards-compat — callers that don't populate the optional 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(); } // ----- builders for test seed rows ----- private static UnsArea Area(string id, string name) => new() { UnsAreaId = id, ClusterId = "c1", Name = name, }; private static UnsLine Line(string id, string areaId, string name) => new() { UnsLineId = id, UnsAreaId = areaId, Name = name, }; private static Equipment Eq(string equipmentId, string lineId, string name) => new() { EquipmentRowId = Guid.NewGuid(), EquipmentId = equipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = lineId, Name = name, MachineCode = "MC-" + name, }; // ----- recording IAddressSpaceBuilder ----- /// Test implementation of IAddressSpaceBuilder that records calls. private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder { /// Gets the browse name of this node. public string BrowseName { get; } = browseName; /// Gets the list of child nodes. public List Children { get; } = new(); /// Gets the list of variables. public List Variables { get; } = new(); /// Gets the list of properties. public List Properties { get; } = new(); /// Creates a folder child node. /// The browse name of the folder. /// The display name (unused). public IAddressSpaceBuilder Folder(string name, string _) { var child = new RecordingBuilder(name); Children.Add(child); return child; } /// Creates a variable node. /// The browse name of the variable. /// The display name (unused). /// The attribute information for the variable. public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr) { var v = new RecordingVariable(name, attr); Variables.Add(v); return v; } /// Adds a property to the node. /// The browse name of the property. /// The data type (unused). /// The value of the property. public void AddProperty(string name, DriverDataType _, object? value) => Properties.Add(new RecordingProperty(name, value)); } /// Recorded property for test verification. private sealed record RecordingProperty(string BrowseName, object? Value); /// Recorded variable for test verification. private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle { /// Gets the full reference of the variable. public string FullReference => AttributeInfo.FullName; /// Marks the variable as an alarm condition. /// The alarm condition information. public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); } }