using System.Collections.Concurrent; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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}"); [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()); 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")); } [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()); applier.MaterialiseHierarchy(composition); sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan")); } [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())); 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())); sdkServer.NodeManager!.FolderCount.ShouldBe(5); } 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; } 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(); public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList(); public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => _calls.Enqueue((folderNodeId, parentNodeId, displayName)); public void RebuildAddressSpace() { } } }