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);