using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; 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; /// /// #85 — verifies builds the UNS /// Area/Line/Equipment folder tree through . /// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test /// drives a real and inspects the resulting predefined-node /// count to prove the folders land in the SDK address space. /// public sealed class Phase7ApplierHierarchyTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-pki-{Guid.NewGuid():N}"); /// Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships. [Fact] public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents() { var sink = new RecordingFolderSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty(), GalaxyTags: Array.Empty()); applier.MaterialiseHierarchy(composition); var calls = sink.Calls; calls.Count.ShouldBe(3); calls[0].ShouldBe(("area-1", null, "Plant North")); calls[1].ShouldBe(("line-1", "area-1", "Cell A")); calls[2].ShouldBe(("eq-1", "line-1", "Pump-1")); } /// Verifies that orphan equipment without a parent line appears under root. [Fact] public void MaterialiseHierarchy_orphan_equipment_hangs_under_root() { var sink = new RecordingFolderSink(); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty(), GalaxyTags: Array.Empty()); applier.MaterialiseHierarchy(composition); sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan")); } /// Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager. [Fact] public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes() { await using var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.Hierarchy", ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var sdkServer = new OtOpcUaSdkServer(); await host.StartAsync(sdkServer, Ct); sdkServer.NodeManager.ShouldNotBeNull(); var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!); var applier = new Phase7Applier(sink, NullLogger.Instance); applier.MaterialiseHierarchy(new Phase7CompositionResult( UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty(), GalaxyTags: Array.Empty())); sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment // Idempotent: re-applying with the same composition doesn't create duplicates. applier.MaterialiseHierarchy(new Phase7CompositionResult( UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty(), GalaxyTags: Array.Empty())); sdkServer.NodeManager!.FolderCount.ShouldBe(5); } /// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager: /// applying the same composition twice yields a single Variable node (no duplicates). This is the /// restart-safety guarantee — HandleRebuild runs on both the apply path and the /// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node /// restart re-runs this pass and must not double-materialise. [Fact] public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent() { await using var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.EquipmentTags", ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var sdkServer = new OtOpcUaSdkServer(); await host.StartAsync(sdkServer, Ct); sdkServer.NodeManager.ShouldNotBeNull(); var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!); var applier = new Phase7Applier(sink, NullLogger.Instance); var composition = new Phase7CompositionResult( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty(), GalaxyTags: Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"), }, }; // Equipment folder first (the variable's parent), then the tag pass — applied twice. applier.MaterialiseHierarchy(composition); applier.MaterialiseEquipmentTags(composition); applier.MaterialiseEquipmentTags(composition); 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); listener.Start(); var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } /// Disposes of resources allocated by this test class. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } } } private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink { private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new(); /// Gets the list of EnsureFolder calls recorded by this sink. public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList(); /// Records a value write (stub implementation for testing). /// The node ID of the variable. /// The value to write. /// The OPC UA quality value. /// The source timestamp in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } /// Records an alarm state write (stub implementation for testing). /// The node ID of the alarm condition. /// Whether the alarm is active. /// Whether the alarm has been acknowledged. /// The source timestamp in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } /// Records a folder creation request. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. /// The display name of the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => _calls.Enqueue((folderNodeId, parentNodeId, displayName)); /// Ensures a variable exists (stub implementation for testing). /// The node ID of the variable. /// The node ID of the parent folder, or null for root. /// The display name of the variable. /// The OPC UA built-in type name. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { } /// Rebuilds the address space (stub implementation for testing). public void RebuildAddressSpace() { } } }