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:
+95
-122
@@ -24,22 +24,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
|
||||
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
|
||||
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
|
||||
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
|
||||
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
|
||||
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
|
||||
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
|
||||
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
|
||||
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
|
||||
/// alarms use.
|
||||
/// v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
|
||||
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
|
||||
/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
|
||||
/// equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
|
||||
/// alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
|
||||
/// condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
|
||||
/// <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
|
||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
|
||||
/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
|
||||
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
|
||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
|
||||
/// Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||
/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
|
||||
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
@@ -49,25 +47,26 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
|
||||
/// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
|
||||
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
|
||||
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
|
||||
|
||||
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
|
||||
/// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
|
||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
|
||||
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
|
||||
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
|
||||
/// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
|
||||
/// <c>SourceNodeId</c>.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "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, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -75,7 +74,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
update.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
update.State.Active.ShouldBeTrue();
|
||||
update.State.Acknowledged.ShouldBeFalse();
|
||||
update.TimestampUtc.ShouldBe(Ts);
|
||||
@@ -87,35 +87,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
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, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped
|
||||
ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "nope",
|
||||
Severity: AlarmSeverity.Low,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
// No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink.
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref
|
||||
/// publishes exactly one <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
|
||||
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
|
||||
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
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 alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -124,8 +120,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -134,38 +130,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
// The OPC UA condition update is UNGATED — it must arrive.
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
|
||||
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId
|
||||
evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId
|
||||
evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name
|
||||
evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath)
|
||||
evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath
|
||||
evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name
|
||||
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
|
||||
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
|
||||
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
|
||||
evt.Message.ShouldBe("temperature high");
|
||||
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
|
||||
// This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
|
||||
// gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
|
||||
// suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
|
||||
evt.HistorizeToAveva.ShouldBeNull();
|
||||
evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose <c>TagConfig.alarm</c> carries
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
|
||||
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
|
||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate
|
||||
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
|
||||
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
|
||||
/// publishes; only the durable row is gated downstream).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
|
||||
/// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_false_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: false);
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -175,7 +164,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -183,21 +172,17 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.AlarmId.ShouldBe("eq-1/temp_hi");
|
||||
// The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
|
||||
evt.AlarmId.ShouldBe(AlarmRawPath);
|
||||
evt.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
|
||||
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
|
||||
/// deliberately opts in is recorded as such on the transition.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
|
||||
/// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_true_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: true);
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -207,7 +192,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -218,16 +203,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.HistorizeToAveva.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host
|
||||
/// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
|
||||
/// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
|
||||
/// the single fleet-wide copy).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
|
||||
/// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
|
||||
/// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
|
||||
[Fact]
|
||||
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
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 alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -239,8 +222,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -249,7 +232,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
update.State.Active.ShouldBeTrue();
|
||||
|
||||
// The cluster-wide alerts publish is gated off on the secondary.
|
||||
@@ -257,27 +240,23 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
|
||||
/// <c>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
|
||||
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
|
||||
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
|
||||
/// </summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
|
||||
/// the operator string and <c>User</c> is <c>"device"</c>.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_operator_comment_flows_to_transition_event()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
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 alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "investigating",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -285,18 +264,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Acknowledge,
|
||||
OperatorComment: "investigating")));
|
||||
|
||||
// OPC UA condition update is ungated — drain it.
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// The published AlarmTransitionEvent must carry the comment + the "device" user marker.
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.Comment.ShouldBe("investigating");
|
||||
evt.User.ShouldBe("device");
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
|
||||
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the
|
||||
/// ScriptedAlarmHostActor test harness.</summary>
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(
|
||||
@@ -304,9 +279,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own
|
||||
/// node (<see cref="TestNode"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
|
||||
/// so the alerts-publish gate observes the local role.</summary>
|
||||
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
|
||||
/// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
|
||||
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
|
||||
{
|
||||
host.Tell(new RedundancyStateChanged(
|
||||
@@ -322,10 +296,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
CorrelationId.NewId()));
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied
|
||||
/// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the
|
||||
/// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
|
||||
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
|
||||
/// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
|
||||
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
@@ -352,47 +326,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
|
||||
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
|
||||
/// condition rather than a value variable.
|
||||
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||
/// DriverInstance(RawFolderId, Name "Modbus") → 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>
|
||||
/// (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
|
||||
/// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
|
||||
/// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||
private static DeploymentId SeedV3AlarmDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name,
|
||||
bool? historizeToAveva = null)
|
||||
string Driver, string Tag, bool? historizeToAveva = null)
|
||||
{
|
||||
// historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
|
||||
// exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||
// historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
|
||||
// a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||
object alarm = historizeToAveva is { } h
|
||||
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
|
||||
: new { alarmType = "OffNormalAlarm", severity = 700 };
|
||||
|
||||
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 { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
|
||||
new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||
},
|
||||
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,
|
||||
}),
|
||||
AccessLevel = 0, // TagAccessLevel.Read
|
||||
TagConfig = JsonSerializer.Serialize(new { alarm }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user