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 AddressSpaceApplierHierarchyTests : 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 AddressSpaceApplier(sink, NullLogger.Instance); var composition = new AddressSpaceComposition( 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")); } /// 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 AddressSpaceApplier(sink, NullLogger.Instance); var composition = new AddressSpaceComposition( 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")); } /// 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 AddressSpaceApplier(sink, NullLogger.Instance); applier.MaterialiseHierarchy(new AddressSpaceComposition( 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 AddressSpaceComposition( 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); } /// 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 AddressSpaceApplier(sink, NullLogger.Instance); var composition = new AddressSpaceComposition( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { EquipmentTags = new[] { new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), }, }; // 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 v3 structure-materialisation pipeline against a real SDK node manager: real Config entities /// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a UnsTagReference) → /// → MaterialiseHierarchy + MaterialiseRawSubtree + /// MaterialiseUnsReferences → . Proves the dual-namespace /// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the /// raw tag's variable projected into its referencing equipment as a second variable. /// Revived from the EquipmentTagsDarkBatch4 skip. It was parked pending "the Batch-4 /// UnsTagReference fan-out", which shipped as v3.0 — but the equipment-tag path it was written /// against was RETIRED rather than lit, so unskipping alone would have failed. Rewritten to the /// shape that actually replaced it. The sibling MaterialiseHierarchy_against_real_SDK still /// covers folders only; this is the layer's only real-SDK proof that variables materialise in both /// namespaces. /// [Fact] public async Task Raw_and_uns_variables_materialise_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(); // The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed" // (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into. var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null }; var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" }; var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" }; var speed = new Tag { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = null, Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }; 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", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" }; var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null }; var composition = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equipment }, new[] { driver }, Array.Empty(), unsTagReferences: new[] { reference }, tags: new[] { speed }, rawFolders: new[] { folder }, devices: new[] { device }); // Compose side: one raw tag at its RawPath, projected once into the referencing equipment. var rawTag = composition.RawTags.ShouldHaveSingleItem(); rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed"); rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw); var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem(); unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed"); unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev1/Speed"); var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); applier.MaterialiseHierarchy(composition); applier.MaterialiseRawSubtree(composition); applier.MaterialiseUnsReferences(composition); // UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device. sdkServer.NodeManager!.FolderCount.ShouldBe(6); // ONE source value, TWO nodes — the raw tag's variable and its UNS projection. This is the // single-source-fan-out shape; a regression that dropped either namespace shows up here as 1. sdkServer.NodeManager!.VariableCount.ShouldBe(2); } /// OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's /// DisplayName IN PLACE against a real SDK node manager (no rebuild, no node count change). Proves the /// full chain: planner emits a RenamedFolders delta → applier drives the surgical /// SdkAddressSpaceSink.UpdateFolderDisplayName → OtOpcUaNodeManager mutates the live FolderState + /// ClearChangeMasks. Before the fix, EnsureFolder early-returned on the existing folder and the /// DisplayName stayed stale until a full RebuildAddressSpace. [Fact] public async Task Area_and_line_rename_updates_existing_folder_display_name_in_place_against_real_SDK() { await using var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.FolderRename", ApplicationUri = $"urn:OtOpcUa.FolderRename:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance); var sdkServer = new OtOpcUaSdkServer(); await host.StartAsync(sdkServer, Ct); sdkServer.NodeManager.ShouldNotBeNull(); var nm = sdkServer.NodeManager!; var sink = new SdkAddressSpaceSink(nm); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); // Materialise the initial hierarchy (area + line + equipment) with the OLD display names. var initial = new AddressSpaceComposition( 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(initial); nm.FolderCount.ShouldBe(3); nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant North"); nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell A"); // Now a rename-only deploy: same ids, new display names on BOTH the area and the line. var renamed = new AddressSpaceComposition( UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant South") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell B") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") }, DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()); var plan = AddressSpacePlanner.Compute(initial, renamed); plan.IsEmpty.ShouldBeFalse(); // rename is no longer a silent no-op plan.RenamedFolders.Count.ShouldBe(2); var outcome = applier.Apply(plan); // In-place: no rebuild, no node-count change — only the DisplayNames were swapped. outcome.RebuildCalled.ShouldBeFalse(); nm.FolderCount.ShouldBe(3); nm.TryGetFolder("area-1")!.DisplayName.Text.ShouldBe("Plant South"); nm.TryGetFolder("line-1")!.DisplayName.Text.ShouldBe("Cell B"); // The unrelated equipment folder is untouched. nm.TryGetFolder("eq-1")!.DisplayName.Text.ShouldBe("Pump-1"); } 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Records an alarm condition write (stub implementation for testing). /// The node ID of the alarm condition. /// The full condition state snapshot. /// The source timestamp in UTC. public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } /// Materialises an alarm condition (stub implementation for testing). /// The alarm node ID (== ScriptedAlarmId). /// The equipment folder node ID. /// The condition display name. /// The domain alarm type. /// The domain severity. public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } /// 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, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _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. /// Whether the node is created read/write. /// The resolved historian tagname (null ⇒ not historized). public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } /// Rebuilds the address space (stub implementation for testing). public void RebuildAddressSpace() { } /// Announces a NodeAdded model-change (stub implementation for testing). public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } } }