diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs index b60e5ff9..aa5db4e7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs @@ -5,6 +5,13 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// The mapped result of grafting discovered nodes under an equipment node. +/// +/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT +/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a +/// global parent-before-child ordering across the whole list. +/// +/// Variables to ensure under the (post-collapse) folders. +/// Driver FullReference -> equipment NodeId, for live-value routing. public sealed record DiscoveredInjectionPlan( IReadOnlyList Folders, IReadOnlyList Variables, @@ -29,7 +36,7 @@ public static class DiscoveredNodeMapper /// /// The folders, variables, and routing map to apply against the OPC UA address space. public static DiscoveredInjectionPlan Map( - string equipmentId, IReadOnlyList nodes, ISet authoredRefs) + string equipmentId, IReadOnlyList nodes, IReadOnlySet authoredRefs) { var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); @@ -65,7 +72,12 @@ public static class DiscoveredNodeMapper var varFolderPath = string.Join('/', segs); var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName); - var varParent = EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); + // Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly + // at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the + // EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace). + var varParent = string.IsNullOrEmpty(varFolderPath) + ? equipmentId + : EquipmentNodeIds.SubFolder(equipmentId, varFolderPath); variables.Add(new DiscoveredVariable( varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); routing[n.FullReference] = varNodeId; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs index e13abc3d..cdc2dca0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs @@ -63,6 +63,36 @@ public sealed class DiscoveredNodeMapperTests }, ignoreOrder: true); } + [Fact] + public void Empty_input_yields_empty_plan() + { + var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty(), authoredRefs: new HashSet()); + result.Folders.ShouldBeEmpty(); + result.Variables.ShouldBeEmpty(); + result.RoutingByRef.ShouldBeEmpty(); + } + + [Fact] + public void Array_metadata_passes_through_unchanged() + { + var node = new DiscoveredNode( + FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"], + BrowseName: "Positions", + DisplayName: "Positions", + FullReference: "10.0.0.5:8193/Axes/Positions", + DataType: DriverDataType.Float64, + IsArray: true, + ArrayDim: 8u, + Writable: false, + IsHistorized: false); + + var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet()); + + result.Variables.ShouldHaveSingleItem(); + result.Variables[0].IsArray.ShouldBeTrue(); + result.Variables[0].ArrayLength.ShouldBe(8u); + } + [Theory] // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float", // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through