feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw realm); wire the single condition as an SDK event notifier of each referencing equipment's UNS folder so one ReportEvent fans to every root without re-reporting per root (which would break Server-object dedup + Part 9 ack correlation). - New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink, forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink + NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the DeferredSinkForwardingReflectionTests realm + forwarding guards + a hand-written forward test. - OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent EnsureFolderIsEventNotifier per equipment folder; tracked per condition in _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no inverse-notifier entry leaks across redeploys. - AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath. - AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts renders the referencing-equipment list as display metadata. - Un-skipped + rewrote the native-alarm dark tests (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a re-trip) + applier wiring test. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+1
@@ -189,6 +189,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
}
|
||||
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,49 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
c.Parent.ShouldBe("Plant/A/dev");
|
||||
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
c.IsNative.ShouldBeTrue();
|
||||
// No referencing equipment ⇒ no multi-notifier wiring.
|
||||
sink.NotifierWirings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
|
||||
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
|
||||
var composition = new AddressSpaceComposition(
|
||||
new[] { new UnsAreaProjection("a1", "filling") },
|
||||
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
|
||||
new[]
|
||||
{
|
||||
new EquipmentNode("EQ-1", "station1", "l1"),
|
||||
new EquipmentNode("EQ-2", "station2", "l2"),
|
||||
},
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||
// The composer emits Area/Line/Equipment NAME paths here.
|
||||
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
// The single condition is materialised ONCE at the RawPath (Raw realm)...
|
||||
var c = sink.Conditions.ShouldHaveSingleItem();
|
||||
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
|
||||
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
|
||||
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
||||
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,6 +199,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
||||
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
||||
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||
@@ -166,6 +210,9 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
||||
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
|
||||
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
|
||||
@@ -2307,6 +2307,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -2332,6 +2333,7 @@ public sealed class AddressSpaceApplierTests
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -2381,6 +2383,7 @@ public sealed class AddressSpaceApplierTests
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}");
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -299,6 +300,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
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();
|
||||
}
|
||||
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user