feat(alarms): consume alarm-commands topic in ScriptedAlarmHostActor (T19)
Subscribe the host to the cluster alarm-commands DPS topic in PreStart and drive the matching ScriptedAlarmEngine op per inbound AlarmCommand. An ownership filter (engine.LoadedAlarmIds) ignores commands for alarms this node does not own; TimedShelve without UnshelveAtUtc and unknown operations are logged + rejected (never thrown); op failures are caught + logged so a faulting op can't fault the actor. Re-projection is left to the engine's existing OnEvent -> OnEngineEmission path. Handler is a Task-returning ReceiveAsync (the project's AK2003 analyzer forbids an async-void Receive delegate), giving ordered awaited async on the actor thread. Adds 3 TestKit tests: ack drives the engine with mapped args, unowned command ignored, missing-UnshelveAtUtc TimedShelve rejected not thrown.
This commit is contained in:
+82
@@ -247,4 +247,86 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
// No LATER RegisterInterest may re-introduce the first (superseded) apply's "M.A" ref.
|
||||
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
|
||||
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
|
||||
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
|
||||
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// Activate so there is something to acknowledge (Acknowledge no-ops on an already-acked alarm).
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active && !m.State.Acknowledged, Timeout);
|
||||
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
|
||||
|
||||
// Acknowledge via the command topic — the host owns alm-1, so AcknowledgeAsync runs.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "Acknowledge", User: "alice", Comment: "ack-note", UnshelveAtUtc: null));
|
||||
|
||||
var acked = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Acknowledged, Timeout);
|
||||
acked.AlarmNodeId.ShouldBe("alm-1");
|
||||
acked.State.Acknowledged.ShouldBeTrue();
|
||||
|
||||
// The transition carries the acting user mapped from cmd.User (proves AcknowledgeAsync got it).
|
||||
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Acknowledged", Timeout);
|
||||
evt.AlarmId.ShouldBe("alm-1");
|
||||
evt.User.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>Ownership filter: an AlarmCommand for an AlarmId this host's engine does NOT own is ignored
|
||||
/// — the engine op never runs, so no AlarmStateUpdate and no alerts transition are produced.</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_for_unowned_alarm_is_ignored()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed; host owns only alm-1
|
||||
|
||||
// Command targets an alarm this engine never loaded — it must be a no-op.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "not-mine", Operation: "Acknowledge", User: "alice", Comment: null, UnshelveAtUtc: null));
|
||||
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no engine op → no projection
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no engine op → no transition
|
||||
}
|
||||
|
||||
/// <summary>Validation: a TimedShelve command missing UnshelveAtUtc is rejected (logged), NOT thrown —
|
||||
/// the actor stays alive and still processes a subsequent valid command, proving it didn't fault.</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_timed_shelve_missing_unshelve_time_is_rejected_not_thrown()
|
||||
{
|
||||
var publish = CreateTestProbe();
|
||||
var mux = CreateTestProbe();
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (host, _) = Spawn(publish, mux);
|
||||
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
|
||||
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
|
||||
|
||||
// TimedShelve with a null UnshelveAtUtc is malformed — the host rejects + logs, does not throw.
|
||||
host.Tell(new AlarmCommand(
|
||||
AlarmId: "alm-1", Operation: "TimedShelve", User: "alice", Comment: null, UnshelveAtUtc: null));
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // rejected → no engine op → no projection
|
||||
|
||||
// Prove the actor survived: activate the alarm and observe the normal projection flow.
|
||||
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
|
||||
var state = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
|
||||
state.AlarmNodeId.ShouldBe("alm-1");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user