test(alarms): assert reconnect-dropped native alarm does not dead-letter; tighten severity doc
v2-ci / build (push) Failing after 38s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Add AllDeadLetters probe to Native_alarm_during_reconnect_is_dropped_not_forwarded so the
test genuinely guards the Reconnecting state's Receive<NativeAlarmRaised> drop handler —
removing that handler would now cause a dead-letter and fail the assertion (false-negative
gap closed). Reword the ScriptedAlarms.md severity-mapping note: "snaps on the first
transition" → "every transition maps … overriding the authored seed from the first
transition onward", clarifying that MapSeverity runs on every event, not just the first.
This commit is contained in:
Joseph Doherty
2026-06-14 22:56:18 -04:00
parent c03361de1b
commit fa2388eabf
2 changed files with 34 additions and 8 deletions
+5 -4
View File
@@ -145,8 +145,9 @@ runtime.
#### Severity mapping #### Severity mapping
The authored `severity` (11000) seeds the OPC UA condition node at materialisation The authored `severity` (11000) seeds the OPC UA condition node at materialisation
time. On the **first native transition** the value snaps to one of four fixed buckets time. Every native transition maps the driver's `AlarmSeverity` to one of four fixed
via `NativeAlarmProjector.MapSeverity`: buckets via `NativeAlarmProjector.MapSeverity`, overriding the authored `severity` seed
from the first transition onward:
| `AlarmSeverity` enum | Projected value | | `AlarmSeverity` enum | Projected value |
|---|---| |---|---|
@@ -168,8 +169,8 @@ by `OtOpcUaNodeManager.MapSeverity` on each `AlarmStateUpdate` write:
| ≥ 800 | `High` | | ≥ 800 | `High` |
Consequently the four authored buckets land as: Low→`MediumLow`, Medium→`Medium`, Consequently the four authored buckets land as: Low→`MediumLow`, Medium→`Medium`,
High→`MediumHigh`, Critical→`High`. The authored `severity` field has no effect after High→`MediumHigh`, Critical→`High`. The authored `severity` field is overridden by
the first transition. live driver events on every transition.
### Runtime flow ### Runtime flow
@@ -1,4 +1,5 @@
using Akka.Actor; using Akka.Actor;
using Akka.Event;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
@@ -94,8 +95,10 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
/// <summary> /// <summary>
/// A native alarm transition that races in while the actor is in <c>Reconnecting</c> is silently /// A native alarm transition that races in while the actor is in <c>Reconnecting</c> is silently
/// dropped (debug log only) — it NEVER reaches the parent as /// dropped (debug log only) — it NEVER reaches the parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> and does NOT dead-letter. The feed /// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> AND is NOT dead-lettered (the
/// re-delivers active alarms once the actor re-enters <c>Connected</c>, so dropping here is safe. /// <c>Reconnecting</c> state's explicit <c>Receive&lt;NativeAlarmRaised&gt;</c> handler consumes and
/// discards it, so it never becomes unhandled). The feed re-delivers active alarms once the actor
/// re-enters <c>Connected</c>, so dropping here is safe.
/// (<see cref="DriverInstanceActor"/> line ~345: the <c>Reconnecting</c> state's /// (<see cref="DriverInstanceActor"/> line ~345: the <c>Reconnecting</c> state's
/// <c>Receive&lt;NativeAlarmRaised&gt;</c> logs a debug message and discards.) /// <c>Receive&lt;NativeAlarmRaised&gt;</c> logs a debug message and discards.)
/// ///
@@ -107,10 +110,27 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
/// (detaches handler), then processes the queued <c>NativeAlarmRaised</c> in Reconnecting /// (detaches handler), then processes the queued <c>NativeAlarmRaised</c> in Reconnecting
/// → drops it. The default 10 s reconnect interval ensures no retry fires during the check. /// → drops it. The default 10 s reconnect interval ensures no retry fires during the check.
/// </para> /// </para>
///
/// <para>
/// Both properties are actively asserted: (a) the parent probe receives no
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>; (b) a dead-letter probe subscribed
/// to <see cref="AllDeadLetters"/> on the <see cref="ActorSystem.EventStream"/> also receives
/// nothing — proving the drop handler is present (removing it would cause a dead-letter and fail
/// this assertion). <c>NativeAlarmRaised</c> is private to the actor, so the dead-letter probe
/// subscribes to the unfiltered <see cref="AllDeadLetters"/> channel; this is safe because
/// exactly one message is injected and the reconnect timer (10 s) cannot fire in the window.
/// </para>
/// </summary> /// </summary>
[Fact] [Fact]
public void Native_alarm_during_reconnect_is_dropped_not_forwarded() public void Native_alarm_during_reconnect_is_dropped_not_forwarded()
{ {
// Subscribe a dead-letter probe BEFORE injecting the alarm so we don't miss any early publish.
// NativeAlarmRaised is private, so we subscribe to the unfiltered AllDeadLetters channel.
// Only one message is injected and the 10 s reconnect timer can't fire in this window, so
// a plain "no dead letters at all" assertion is safe and non-flaky.
var deadLetters = CreateTestProbe();
Sys.EventStream.Subscribe(deadLetters.Ref, typeof(AllDeadLetters));
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window. // Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
var driver = new AlarmSourceStubDriver(); var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe(); var parent = CreateTestProbe();
@@ -134,13 +154,18 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
SourceTimestampUtc: DateTime.UtcNow, SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise)); Kind: AlarmTransitionKind.Raise));
// (a) The parent must NOT receive AttributeAlarmPublished from that alarm.
// The actor processes: (1) ForceReconnect → Reconnecting (handler detached); // The actor processes: (1) ForceReconnect → Reconnecting (handler detached);
// (2) NativeAlarmRaised → dropped (debug log, no forward). // (2) NativeAlarmRaised → dropped (debug log, no forward).
// The parent must NOT receive AttributeAlarmPublished from that alarm.
// Wait generously — the default reconnect interval of 10 s means no retry fires here. // Wait generously — the default reconnect interval of 10 s means no retry fires here.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The actor must still be alive — Watch + no Terminated (not crashed or dead-lettered). // (b) The alarm must also NOT have dead-lettered — the Reconnecting state's explicit
// Receive<NativeAlarmRaised> handler must have consumed it. If that handler were removed the
// message would become unhandled → AllDeadLetters, and this assertion would catch the regression.
deadLetters.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
// The actor must still be alive — Watch + no Terminated.
Watch(actor); Watch(actor);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100)); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
} }