using Opc.Ua; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// R2-07 Phase 2 (T8/T9/T10) — surgical IN-PLACE removal on the node manager: a single value variable /// (), a single Part 9 alarm condition /// (), and an equipment folder + its whole /// descendant subtree with notifier demotion (). /// Each removal detaches only the scoped node(s), cleans the matching maps, and raises a Part 3 /// NodeDeleted model-change; an unknown id returns false (the caller falls back to a full rebuild). /// public sealed class NodeManagerSurgicalRemoveTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-surgical-remove-{Guid.NewGuid():N}"); // ---------- T8: RemoveVariableNode ---------- /// Ensure a variable then remove it: it disappears from the maps (TryGetVariable null, /// VariableCount decremented), its historized-tagname registration is dropped, and the call returns /// true. [Trait("Category", "Unit")] [Fact] public async Task RemoveVariableNode_drops_variable_and_historian_registration() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false); var countBefore = nm.VariableCount; nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue(); nm.RemoveVariableNode("eq-1/A").ShouldBeTrue(); nm.TryGetVariable("eq-1/A").ShouldBeNull(); nm.VariableCount.ShouldBe(countBefore - 1); nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeFalse(); // The sibling variable is untouched. nm.TryGetVariable("eq-1/B").ShouldNotBeNull(); await host.DisposeAsync(); } /// Removing an unknown variable id returns false (map drift ⇒ caller rebuilds). [Trait("Category", "Unit")] [Fact] public async Task RemoveVariableNode_unknown_id_returns_false() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse(); await host.DisposeAsync(); } /// The built removed-node event announces the deleted node with verb NodeDeleted. [Trait("Category", "Unit")] [Fact] public async Task Built_removed_event_announces_the_deleted_node_with_NodeDeleted_verb() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false); var e = nm.BuildNodesRemovedModelChange("eq-1/A"); e.ShouldNotBeNull(); e.Changes.ShouldNotBeNull(); var changes = e.Changes.Value; changes.Length.ShouldBe(1); changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeDeleted); await host.DisposeAsync(); } // ---------- T9: RemoveAlarmConditionNode ---------- /// Materialise a native alarm condition then remove it: it disappears from the condition map and /// the native flag is cleared; unknown id returns false. [Trait("Category", "Unit")] [Fact] public async Task RemoveAlarmConditionNode_drops_condition_and_native_flag() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue(); nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse(); nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false await host.DisposeAsync(); } /// A scripted condition removes cleanly too (no native flag was set). [Trait("Category", "Unit")] [Fact] public async Task RemoveAlarmConditionNode_removes_scripted_condition() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false); nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); await host.DisposeAsync(); } // ---------- T10: RemoveEquipmentSubtree ---------- /// Remove an equipment folder carrying a sub-folder, variables (one historized), and a condition: /// every descendant disappears from every map, the notifier registration is demoted, and a SIBLING /// equipment is fully intact. [Trait("Category", "Unit")] [Fact] public async Task RemoveEquipmentSubtree_removes_all_descendants_and_leaves_siblings_intact() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition. nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag"); nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false); nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); // Sibling equipment eq-2 that must survive untouched. nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false); nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); // Every eq-1 descendant is gone from every map. nm.TryGetFolder("eq-1").ShouldBeNull(); nm.TryGetFolder("eq-1/Diag").ShouldBeNull(); nm.TryGetVariable("eq-1/A").ShouldBeNull(); nm.TryGetVariable("eq-1/Diag/T").ShouldBeNull(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull(); nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeFalse(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse(); // Sibling eq-2 is fully intact. nm.TryGetFolder("eq-2").ShouldNotBeNull(); nm.TryGetVariable("eq-2/S").ShouldNotBeNull(); // Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the // eq-1 demotion) — proves no orphaned root-notifier ref broke the event path. Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false)); await host.DisposeAsync(); } /// Removing an unknown equipment id returns false (map drift ⇒ caller rebuilds). [Trait("Category", "Unit")] [Fact] public async Task RemoveEquipmentSubtree_unknown_id_returns_false() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse(); await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.SurgicalRemoveTest", ApplicationUri = $"urn:OtOpcUa.SurgicalRemoveTest:{Guid.NewGuid():N}", OpcUaPort = AllocateFreePort(), PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var server = new OtOpcUaSdkServer(); await host.StartAsync(server, Ct); return (host, server); } 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; } /// Cleans up the PKI root directory. public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }