diff --git a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
index 11824f39..93b5e6ac 100644
--- a/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
+++ b/docs/plans/2026-06-06-equipment-namespace-structure-milestone.md.tasks.json
@@ -8,7 +8,7 @@
{"id": 2, "nativeTaskId": 88, "subject": "Task 2: Materialise equipment signals in the live rebuild", "status": "completed", "blockedBy": [87]},
{"id": 3, "nativeTaskId": 89, "subject": "Task 3: Friendly browse names for UNS folders", "status": "completed", "blockedBy": [87]},
{"id": 4, "nativeTaskId": 90, "subject": "Task 4: Idempotency + restart-safety", "status": "completed", "blockedBy": [88]},
- {"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "pending", "blockedBy": [88, 89]}
+ {"id": 5, "nativeTaskId": 91, "subject": "Task 5: docker-dev integration verification + tool support", "status": "completed", "blockedBy": [88, 89]}
],
"lastUpdated": "2026-06-06"
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
index 46d3a95f..ae8610fe 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -4,6 +4,8 @@ using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
@@ -158,6 +160,61 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
}
+ ///
+ /// Full structure-materialisation pipeline against a real SDK node manager: real Config
+ /// entities (Area / Line / Equipment + an Equipment-namespace Tag) →
+ /// → MaterialiseHierarchy + MaterialiseEquipmentTags → . Proves
+ /// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
+ /// Variable in a live OPC UA address space (structure-only; live values are a later milestone).
+ /// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
+ /// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
+ /// as a follow-up.
+ ///
+ [Fact]
+ public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
+ {
+ await using var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.EquipmentE2e",
+ ApplicationUri = $"urn:OtOpcUa.EquipmentE2e:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ var sdkServer = new OtOpcUaSdkServer();
+ await host.StartAsync(sdkServer, Ct);
+ sdkServer.NodeManager.ShouldNotBeNull();
+
+ // One area / line / equipment + a Modbus FK driver in an Equipment-kind namespace, with a
+ // single equipment-bound Tag (the signal). Equipment.Name is the UNS browse segment.
+ var ns = new Namespace { NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq" };
+ var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", NamespaceId = "ns-eq", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
+ var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
+ var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
+ var equipment = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
+ var tag = new Tag { TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}" };
+
+ var composition = Phase7Composer.Compose(
+ new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
+ Array.Empty(), new[] { tag }, new[] { ns });
+
+ // Compose-side EquipmentTags extraction (the inverse of the Galaxy filter).
+ var planned = composition.EquipmentTags.ShouldHaveSingleItem();
+ planned.EquipmentId.ShouldBe("eq-1");
+ planned.FullName.ShouldBe("40001");
+
+ var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
+ var applier = new Phase7Applier(sink, NullLogger.Instance);
+ applier.MaterialiseHierarchy(composition);
+ applier.MaterialiseEquipmentTags(composition);
+
+ sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment
+ sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
+ }
+
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);