3cf3576c75
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
239 lines
12 KiB
C#
239 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// v3 Batch 4 (B4-WP4) — the native-alarm <b>multi-notifier</b> wiring +
|
|
/// <b>teardown symmetry</b> on <see cref="OtOpcUaNodeManager"/>. A native alarm is a SINGLE Part 9
|
|
/// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
|
|
/// <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> wires that one condition as an event notifier of
|
|
/// EACH referencing equipment's UNS folder so one <c>ReportEvent</c> 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).
|
|
/// </summary>
|
|
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}");
|
|
|
|
/// <summary>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.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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).</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Wave C review M3 — <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> 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.)</summary>
|
|
[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<OpcUaApplicationHost>.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;
|
|
}
|
|
|
|
/// <summary>Cleans up the PKI root directory.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot))
|
|
{
|
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
|
catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
}
|