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:
+51
-56
@@ -19,21 +19,22 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the inbound native-condition <b>acknowledge</b> routing wired into
|
||||
/// <see cref="DriverHostActor"/> (H6d): an OPC UA client Acknowledges a NATIVE condition, the
|
||||
/// node manager invokes <c>NativeAlarmAckRouter</c>, and the host (NEXT task) Tells a
|
||||
/// <see cref="DriverHostActor.RouteNativeAlarmAck"/> in. The host resolves the condition NodeId →
|
||||
/// owning <c>(DriverInstanceId, FullName)</c> via the <c>_driverRefByAlarmNodeId</c> inverse map
|
||||
/// (built alongside the alarm forward map in <c>PushDesiredSubscriptions</c>), applies the SAME
|
||||
/// v3 Batch 4 (B4-WP4) — the inbound native-condition <b>acknowledge</b> routing wired into
|
||||
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges
|
||||
/// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes
|
||||
/// <c>NativeAlarmAckRouter</c> and the host receives a <see cref="DriverHostActor.RouteNativeAlarmAck"/>.
|
||||
/// The host resolves the condition NodeId (== the RawPath) → owning <c>(DriverInstanceId, RawPath)</c> via
|
||||
/// the <c>_driverRefByAlarmNodeId</c> inverse map (built alongside the alarm forward map in
|
||||
/// <c>PushDesiredSubscriptions</c> from the alarm-bearing <c>composition.RawTags</c>), applies the SAME
|
||||
/// primary gate the inbound write path uses, and routes to the owning driver child's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors <c>DriverHostActorWriteRoutingTests</c>: a real apply through the existing harness
|
||||
/// spawns a real (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording
|
||||
/// <see cref="IAlarmSource"/> driver, so the inverse map is populated authentically and the
|
||||
/// forwarded acknowledge request can be observed. The seeded tag carries an <c>alarm</c> object so
|
||||
/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
|
||||
/// Mirrors <c>DriverHostActorLiveValueTests</c>: a real apply through the harness spawns a real
|
||||
/// (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording <see cref="IAlarmSource"/>
|
||||
/// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the
|
||||
/// forwarded acknowledge request can be observed. The seeded raw tag carries an <c>alarm</c> object so it
|
||||
/// materialises as a Part 9 condition at its RawPath, not a value variable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
|
||||
@@ -42,31 +43,31 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
|
||||
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
|
||||
/// principal, and the comment.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag.
|
||||
private const string AlarmRawPath = "Plant/gw/dev1/temp_hi";
|
||||
|
||||
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId
|
||||
/// (== the RawPath) forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, correlated on the RawPath, with the operator principal + the
|
||||
/// comment.</summary>
|
||||
[Fact]
|
||||
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
|
||||
// → condition NodeId "eq-1/temp_hi".
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
// The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
|
||||
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Acks.Count.ShouldBe(1);
|
||||
recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
|
||||
recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
|
||||
recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath);
|
||||
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
|
||||
recorder.Acks[0].Comment.ShouldBe("cmt");
|
||||
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
||||
}, duration: Timeout);
|
||||
@@ -79,27 +80,24 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
|
||||
|
||||
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
||||
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called — a secondary keeps its address
|
||||
/// space warm but must not push commands to the shared upstream alarm system.</summary>
|
||||
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called.</summary>
|
||||
[Fact]
|
||||
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
@@ -112,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
@@ -137,51 +135,48 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
||||
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object so
|
||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null <c>EquipmentTagAlarmInfo</c> —
|
||||
/// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
|
||||
/// <c>DriverInstances</c> row carries a non-Windows-only <c>DriverType</c> ("GalaxyMxGateway") + an
|
||||
/// Enabled flag so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
|
||||
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||
/// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag)
|
||||
/// with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||
/// <c>EquipmentTagAlarmInfo</c> — making the raw tag a Part 9 condition at its RawPath. The non-Windows
|
||||
/// <c>DriverType</c> + Enabled flag spawn a REAL <see cref="DriverInstanceActor"/> child.
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name)
|
||||
private static DeploymentId SeedV3AlarmDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string Driver, string Tag)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = Driver,
|
||||
Name = Driver,
|
||||
RawFolderId = "rf-plant",
|
||||
Name = "gw",
|
||||
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
ClusterId = "c1",
|
||||
},
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-0",
|
||||
EquipmentId = Equip,
|
||||
DriverInstanceId = Driver,
|
||||
Name,
|
||||
FolderPath = Folder,
|
||||
DeviceId = $"{Driver}:dev1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = Tag,
|
||||
DataType = "Boolean",
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
FullName,
|
||||
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
|
||||
}),
|
||||
AccessLevel = 0, // TagAccessLevel.Read
|
||||
TagConfig = JsonSerializer.Serialize(new { alarm = new { alarmType = "OffNormalAlarm", severity = 700 } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user