fix(alarms): historize the real operator for shelve/unshelve/enable/disable transitions

This commit is contained in:
Joseph Doherty
2026-06-11 13:14:00 -04:00
parent 5ea6e9d7d9
commit 56750e110f
2 changed files with 39 additions and 1 deletions
@@ -512,7 +512,13 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
{
EmissionKind.Acknowledged => e.Condition.LastAckUser ?? "system",
EmissionKind.Confirmed => e.Condition.LastConfirmUser ?? "system",
EmissionKind.CommentAdded => e.Condition.Comments.Count > 0 ? e.Condition.Comments[^1].User : "system",
// Shelve / unshelve / enable / disable / comment ops each append the acting user as the LAST audit
// entry on the emitted condition (engine auto-unshelve appends "system"); read it from there.
EmissionKind.CommentAdded
or EmissionKind.Shelved
or EmissionKind.Unshelved
or EmissionKind.Enabled
or EmissionKind.Disabled => e.Condition.Comments.Count > 0 ? e.Condition.Comments[^1].User : "system",
_ => "system",
};
@@ -612,6 +612,38 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
evtFalse.HistorizeToAveva.ShouldBe(false);
}
/// <summary>OneShotShelve transition carries the operator's identity: an operator-driven OneShotShelve
/// drives <c>OneShotShelveAsync</c> — the resulting <see cref="AlarmTransitionEvent"/>(<c>"Shelved"</c>)
/// on the alerts topic must carry <c>User == cmd.User</c> (the acting operator), NOT the generic
/// <c>"system"</c> sentinel. This verifies the <see cref="ScriptedAlarmHostActor"/>
/// <c>TransitionUser</c> arm for <see cref="EmissionKind.Shelved"/>.</summary>
[Fact]
public void Shelved_transition_carries_operator_user()
{
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 shelve.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.State.Active, Timeout);
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
// Shelve via a one-shot shelve command — the host owns alm-1, so OneShotShelveAsync runs.
host.Tell(new AlarmCommand(
AlarmId: "alm-1", Operation: "OneShotShelve", User: "carol", Comment: null, UnshelveAtUtc: null));
// The Shelved AlarmTransitionEvent must carry the operator's identity, not "system".
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Shelved", Timeout);
evt.AlarmId.ShouldBe("alm-1");
evt.User.ShouldBe("carol"); // operator — NOT "system"
}
/// <summary>Absent-node default-emit (A1): a <see cref="RedundancyStateChanged"/> snapshot that
/// contains ONLY other nodes (the host's own <see cref="LocalNode"/> is absent) must leave the
/// cached local role unchanged (null/unknown) — the host therefore defaults to emit, publishing