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.QueueEvent → IsEventContainedInQueue), 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(