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