From 3cf3576c758714b80bb2da832e14a5b06c3805a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 12:52:34 -0400 Subject: [PATCH] =?UTF-8?q?fix(v3-batch4-wp4):=20alarm=20fan-out=20hardeni?= =?UTF-8?q?ng=20=E2=80=94=20event-delivery=20test,=20resolution-failure=20?= =?UTF-8?q?meter,=20teardown=20guards=20(Wave=20C=20review=20M1/M2/M3/L1/L?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two equipment folders, fires ONE transition, and asserts a Server-object subscriber gets EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping Server + equipment-folder subscribers each get exactly one copy. Captures via the subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents path delivers nothing for these conditions in the SDK client — the working capture is the fast callback). M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented the DisplayName==Name coupling as a binding invariant. Tests: Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed. M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set, bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced equipment receiving the alarm; binding guard comment added on the classifier's ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set. L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry). L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision (defense-in-depth; UNS uniqueness prevents it). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../AddressSpaceApplier.cs | 45 ++- .../AddressSpaceChangeClassifier.cs | 8 + .../OtOpcUaNodeManager.cs | 32 ++ ...iveAlarmMultiNotifierEventDeliveryTests.cs | 282 ++++++++++++++++++ .../AddressSpaceApplierRawUnsTests.cs | 84 ++++++ .../NodeManagerMultiNotifierAlarmTests.cs | 22 ++ 6 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 6c6caea9..2c6b8d41 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -716,8 +716,21 @@ public sealed class AddressSpaceApplier .Select(id => id!) .Distinct(StringComparer.Ordinal) .ToList(); - if (equipFolderNodeIds.Count > 0 && - !SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns)) + if (equipFolderNodeIds.Count == 0) + { + // Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved + // to an EquipmentId folder — the native fan-out to those equipment is silently absent. + // Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead + // of swallowing zero operator signal. Today this only happens on a broken UNS ancestry / + // an invalid segment (the composer would have dropped the equipment too); the latent + // risk is a future editable Area/Line display-name feature breaking the path↔id coupling + // (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests). + _logger.LogWarning( + "AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})", + t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths)); + failed++; + } + else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns)) { failed++; } @@ -1082,13 +1095,17 @@ public sealed class AddressSpaceApplier /// as name paths (V3NodeIds.Uns(areaName, lineName, equipName)), but the equipment folders were /// materialised under their logical EquipmentId by , so the /// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path - /// construction EXACTLY (area/line/equipment DisplayName == the UNS level Name, so the paths match - /// byte-for-byte); an invalid segment throws in and is skipped (the same - /// drop the composer applies), so an entry the composer produced always resolves here. + /// construction EXACTLY — path↔id coupling invariant: the composer builds the path from the + /// Area/Line/Equipment Name; this inverts it via the projected DisplayName, which the + /// composer sets to that same Name at every level (verified by + /// AddressSpaceComposerPathParityTests). A future editable Area/Line display-name feature that + /// lets DisplayName != Name would silently break this inversion — that test is the tripwire. + /// An invalid segment throws in and is skipped (the same drop the composer + /// applies), so an entry the composer produced always resolves here. /// /// The composition carrying the UNS topology + equipment nodes. /// A map from each resolvable equipment folder path to its EquipmentId. - private static IReadOnlyDictionary BuildEquipmentIdByFolderPath(AddressSpaceComposition composition) + private IReadOnlyDictionary BuildEquipmentIdByFolderPath(AddressSpaceComposition composition) { var areaName = composition.UnsAreas .GroupBy(a => a.UnsAreaId, StringComparer.Ordinal) @@ -1103,8 +1120,20 @@ public sealed class AddressSpaceApplier if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue; if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue; if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue; - try { map[V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName)] = e.EquipmentId; } - catch (ArgumentException) { /* invalid segment — dropped, mirroring the composer */ } + string path; + try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); } + catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ } + // Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id + // (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the + // composer's own last-write-wins spot) rather than silently pick one — a collision here would send + // the alarm's notifier to the wrong equipment folder. + if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal)) + { + _logger.LogWarning( + "AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this", + path, existing, e.EquipmentId); + } + map[path] = e.EquipmentId; } return map; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index 8a05bc74..52082009 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -58,6 +58,14 @@ public static class AddressSpaceChangeClassifier // display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical // in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change // alongside pure adds still rebuilds. + // + // WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier + // wiring correct today. A native alarm's set of referencing equipment lives in + // RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference + // makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers + // cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm + // notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus + // an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events. if (plan.ChangedEquipment.Count > 0 || plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index fedca5c5..e882ad1e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -762,6 +762,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // type/severity on redeploy) reflects cleanly instead of leaking the old node. if (_alarmConditions.TryRemove(conditionKey, out var existing)) { + // Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no + // referencing equipment folder keeps a dangling inverse-notifier entry to the dropped + // AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no + // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown + // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. + UnwireAlarmNotifiers(conditionKey); existing.Parent?.RemoveChild(existing); PredefinedNodes?.Remove(existing.NodeId); } @@ -892,6 +898,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders); /// + /// + /// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any + /// previously-tracked folder whose id is no longer in + /// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always + /// passes the COMPLETE referencing-equipment set for the condition, and a de-reference arrives as a + /// ChangedRawTags full rebuild (which clears the wiring), so the unwire leg is a no-op on the + /// current paths — but it makes this method correct for a FUTURE surgical ChangedRawTags path + /// (see AddressSpaceChangeClassifier's ChangedRawTags → Rebuild branch, which carries the + /// matching binding guard). + /// public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { @@ -925,6 +941,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 _alarmNotifierWiring[alarmKey] = wiring; } + // The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller + // supplied, regardless of whether each is currently materialised. Reconcile against it below. + var desiredKeys = notifierFolderNodeIds + .Select(id => MapKey(notifierFolderRealm, id)) + .ToHashSet(StringComparer.Ordinal); + + // Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a + // genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the + // folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder + // (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too. + foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList()) + { + wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true); + wiring.Folders.Remove(wired); + } + foreach (var folderNodeId in notifierFolderNodeIds) { if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder)) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs new file mode 100644 index 00000000..8674ecf2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/NativeAlarmMultiNotifierEventDeliveryTests.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// v3 Batch 4 (B4-WP4) — the HEADLINE over-the-wire proof for the multi-notifier native alarm (Wave C +/// review M1): a SINGLE native transition on a condition wired to MULTIPLE notifier roots (its raw +/// device-folder parent + N referencing equipment folders) delivers exactly one event copy to a +/// Server-object event subscriber — the Part 9 shared-InstanceStateSnapshot dedup +/// (MonitoredItem.QueueEventIsEventContainedInQueue), NOT one copy per root. This is the +/// regression guard the design's "duplicate-report fallback rejected" decision rests on, and it is exactly +/// the multi-root topology (2 equipment folders → 2 notifier paths up to Server) where a per-root +/// re-report would surface as double delivery. +/// +/// Boots the real exactly as SubscriptionSurvivalTests does, +/// materialises the raw condition + two referencing equipment folders + the notifier wiring through the +/// production , opens a real client subscription with event monitored +/// items (captured via the subscription , keyed by +/// ClientHandle), fires ONE transition via , +/// and counts the delivered events. Heavy in-process server+client integration — runs in the serial +/// integration pass, not the lightweight unit sweep. +/// +public sealed class NativeAlarmMultiNotifierEventDeliveryTests +{ + private const string ServerUri = "urn:OtOpcUa.MultiNotifierEventDelivery"; + + // The raw device-oriented topology (ns=Raw) + two referencing equipment folders (ns=UNS). + private const string RawDeviceFolder = "Plant/Modbus/dev1"; + private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi"; + private const string Equip1 = "EQ-filling-line1-station1"; + private const string Equip2 = "EQ-filling-line2-station2"; + private const string AlarmMessage = "over-temperature (multi-notifier test)"; + + // Index of the Message select clause in the event filter (see BuildEventFilter): [0]=EventId, [1]=Message. + private const int MessageFieldIndex = 1; + + /// One native transition on a condition wired to two equipment folders delivers EXACTLY ONE event + /// to a Server-object subscriber (not one per notifier root) — the shared-snapshot queue dedup. + [Fact] + public async Task Single_transition_delivers_exactly_one_event_at_the_Server_object() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-evt-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + WireSingleConditionToTwoEquipment(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var collector = new EventCollector(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + subscription.FastEventCallback = collector.OnEvents; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + var serverItem = AddEventItem(subscription, ObjectIds.Server); + await subscription.ApplyChangesAsync(ct); + + // Fire ONE genuine transition (inactive+acked → active+unacked). No ConditionRefresh is issued, so + // the ONLY event is this transition — a per-root re-report would show up as a second copy. + sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); + + await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5)); + // Settle past the publishing interval so any (erroneous) duplicate would have arrived. + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + + collector.CountFor(serverItem.ClientHandle).ShouldBe(1, + "the single condition must deliver exactly ONE event to the Server object despite two notifier " + + "roots (2 equipment folders) — a per-root re-report would deliver 2."); + + await subscription.DeleteAsync(true, ct); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Overlapping subscriptions: event monitored items at the Server object AND an equipment folder + /// each receive EXACTLY ONE copy of the one transition — the multi-root topology does not multiply delivery + /// to any single subscriber, and the equipment-folder subscriber (ns=UNS) sees the native alarm without + /// touching ns=Raw. + [Fact] + public async Task Overlapping_folder_and_server_subscriptions_each_receive_exactly_one_copy() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-ovl-{Guid.NewGuid():N}"); + var port = AllocateFreePort(); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Overlap", ct); + await using var _ = host; + + var sink = new SdkAddressSpaceSink(server.NodeManager!); + WireSingleConditionToTwoEquipment(sink); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); + unsNs.ShouldBeGreaterThan((ushort)0); + + var collector = new EventCollector(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + subscription.FastEventCallback = collector.OnEvents; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + + var serverItem = AddEventItem(subscription, ObjectIds.Server); + var equipItem = AddEventItem(subscription, new NodeId(Equip1, unsNs)); + await subscription.ApplyChangesAsync(ct); + + sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw); + + await WaitUntilAsync(() => + collector.CountFor(serverItem.ClientHandle) >= 1 && + collector.CountFor(equipItem.ClientHandle) >= 1, + TimeSpan.FromSeconds(5)); + await Task.Delay(TimeSpan.FromMilliseconds(700), ct); + + // Each subscriber gets exactly one copy — the shared snapshot is deduped per monitored item; the + // multi-root topology never multiplies delivery. + collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "Server-object subscriber"); + collector.CountFor(equipItem.ClientHandle).ShouldBe(1, "equipment-folder subscriber (ns=UNS) sees the native alarm"); + + await subscription.DeleteAsync(true, ct); + } + finally + { + SafeDelete(pkiRoot + "-srv"); + } + } + + /// Materialise the raw device folder + the single native condition at its RawPath + two referencing + /// equipment folders (UNS), then wire the condition as an event notifier of both equipment folders. + private static void WireSingleConditionToTwoEquipment(SdkAddressSpaceSink sink) + { + sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw); + sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true); + sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns); + sink.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns); + sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns); + } + + private static AlarmConditionSnapshot ActiveSnapshot() => + new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage); + + private static MonitoredItem AddEventItem(Subscription subscription, NodeId source) + { + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = source, + AttributeId = Attributes.EventNotifier, + Filter = BuildEventFilter(), + QueueSize = 100, + SamplingInterval = 0, + }; + subscription.AddItem(item); + return item; + } + + /// An EventFilter selecting [0]=EventId (dedup identity) and [1]=Message (our discriminator). + private static EventFilter BuildEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId); + filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message); + return filter; + } + + /// Collects events delivered to a subscription's , + /// tallying per monitored-item ClientHandle and filtering to our unique alarm Message (so unrelated server + /// events / refresh brackets never count). + private sealed class EventCollector + { + private readonly object _gate = new(); + private readonly Dictionary _byHandle = new(); + + public void OnEvents(Subscription subscription, EventNotificationList notification, IList stringTable) + { + lock (_gate) + { + foreach (var e in notification.Events) + { + if (e.EventFields.Count > MessageFieldIndex && + e.EventFields[MessageFieldIndex].Value is LocalizedText lt && + lt.Text == AlarmMessage) + { + _byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1; + } + } + } + } + + public int CountFor(uint clientHandle) + { + lock (_gate) return _byHandle.GetValueOrDefault(clientHandle); + } + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync( + int port, string pkiRoot, string appUri, CancellationToken ct) + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }; + var server = new OtOpcUaSdkServer(); + var host = new OpcUaApplicationHost(options, NullLogger.Instance); + await host.StartAsync(server, ct); + return (server, host); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient", + ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var endpointConfiguration = EndpointConfiguration.Create(appConfig); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue("condition not met within timeout"); + } + + private static int AllocateFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static void SafeDelete(string dir) + { + if (Directory.Exists(dir)) + { + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs index c4fc7bf7..01f3969c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierRawUnsTests.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging.Abstractions; 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; using ZB.MOM.WW.OtOpcUa.OpcUaServer; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; @@ -152,6 +154,88 @@ public sealed class AddressSpaceApplierRawUnsTests w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true); } + /// + /// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name + /// coupling). The composer builds a native alarm's ReferencingEquipmentPaths from the + /// Area/Line/Equipment Names; the applier's BuildEquipmentIdByFolderPath inverts those + /// paths back to the EquipmentId via the projected DisplayName. Feed a REAL composer output + /// (not a hand-built plan) through and assert + /// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree + /// end-to-end. If a future editable Area/Line display-name feature made DisplayName != Name, this + /// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first. + /// + [Fact] + public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier() + { + // Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" / + // Line "line-1" / Equipment "station-1"). + var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; + var driver = new DriverInstance + { + DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", + Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", + }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; + var speed = new Tag + { + TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + }; + var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; + var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; + var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" }; + + var composition = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { refSpeed }, + tags: new[] { speed }, + rawFolders: new[] { folder }, + devices: new[] { device }, + tagGroups: Array.Empty()); + + // The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names. + var alarmTag = composition.RawTags.Single(t => t.Alarm is not null); + alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") }); + + // The applier inverts that path back to the EquipmentId folder and wires the notifier there. + var sink = new RealmRecordingSink(); + NewApplier(sink).MaterialiseRawSubtree(composition); + + var w = sink.NotifierWirings.ShouldHaveSingleItem(); + w.AlarmNodeId.ShouldBe(alarmTag.NodeId); + w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId + } + + /// Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an + /// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed + /// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the + /// condition itself is still materialised. + [Fact] + public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed() + { + var sink = new RealmRecordingSink(); + // No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder. + var composition = new AddressSpaceComposition( + Array.Empty(), Array.Empty(), Array.Empty()) + { + RawTags = new[] + { + new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean", + Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700), + ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }), + }, + }; + + var failed = NewApplier(sink).MaterialiseRawSubtree(composition); + + failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip) + sink.NotifierWirings.ShouldBeEmpty(); // nothing wired + sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised + } + [Fact] public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs index 6840da86..a377cca0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMultiNotifierAlarmTests.cs @@ -177,6 +177,28 @@ public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable 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(