using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// v3 Batch 4 (B4-WP4) — the native-alarm multi-notifier wiring + /// teardown symmetry on . A native alarm is a SINGLE Part 9 /// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm); /// wires that one condition as an event notifier of /// EACH referencing equipment's UNS folder so one ReportEvent fans one event to every referencing /// equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent, /// a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal /// / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks /// across redeploys (exactly N notifiers after a re-trip, not 2N). /// public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private const string RawAlarm = "Plant/Modbus/dev1/temp_hi"; private const string RawDeviceFolder = "Plant/Modbus/dev1"; private const string Equip1 = "EQ-filling-line1-station1"; private const string Equip2 = "EQ-filling-line2-station2"; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-multi-notifier-{Guid.NewGuid():N}"); /// Materialise the raw device folder + the single native condition + two equipment folders, then /// wire the condition as a notifier of both. Returns the node manager. private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; // Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath. nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw); // Two referencing equipment folders (UNS realm). nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns); return (host, nm); } /// The single condition is wired as a notifier of EACH referencing equipment folder: the condition /// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and /// each folder becomes a root (Server-object) event notifier. [Trait("Category", "Unit")] [Fact] public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); // One inverse-notifier entry per equipment folder on the single condition. nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); // Each equipment folder holds the inverse entry back to the condition + is a root notifier. nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1); nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue(); nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue(); await host.DisposeAsync(); } /// Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the /// notifier entries — the SDK dedups by node reference and the tracking dedups its list. [Trait("Category", "Unit")] [Fact] public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1); await host.DisposeAsync(); } /// A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present /// folders are still wired. A missing condition is likewise a no-op. [Trait("Category", "Unit")] [Fact] public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip() { var (host, nm) = await BootWithTwoEquipmentAsync(); // "EQ-missing" was never materialised — it is skipped; Equip1 is still wired. nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); // An unmaterialised condition id is a no-op (never throws). Should.NotThrow(() => nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns)); await host.DisposeAsync(); } /// Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down /// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed /// condition (no dangling reference). [Trait("Category", "Unit")] [Fact] public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue(); // Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown). nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull(); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); await host.DisposeAsync(); } /// Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw /// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops /// exactly one notifier and keeps the other. [Trait("Category", "Unit")] [Fact] public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue(); // The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder. nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull(); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1); nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue(); await host.DisposeAsync(); } /// Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise + /// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the /// prior notifier pairs bidirectionally before dropping the nodes. [Trait("Category", "Unit")] [Fact] public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); // Redeploy (the full-rebuild path). nm.RebuildAddressSpace(); nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull(); // Re-materialise the whole thing + re-wire (what the applier does every apply). nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw); nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); // Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild. nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1); await host.DisposeAsync(); } /// Wave C review M3 — is a RECONCILE: re-wiring /// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced /// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink /// arrives as a full rebuild.) [Trait("Category", "Unit")] [Fact] public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set() { var (host, nm) = await BootWithTwoEquipmentAsync(); nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2); // Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally. nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns); nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1); nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1); nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition await host.DisposeAsync(); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.MultiNotifierTest", ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{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 */ } } } }