fix(alarms): native-alarm re-statements no longer duplicate history or hide acked alarms (#656)

- AlarmTransitionKind.ActiveAcknowledged (appended): active AND acked applied as ONE projection.
  Galaxy ActiveAcked snapshot entries and OpcUaClient inactive->(active,acked) use it; the
  projector writes 'Activated' only if not already active, 'Acknowledged' only if not acked.
- AlarmEventArgs.IsRestatement (optional, default false): set only by Galaxy snapshot entries
  and OpcUaClient first sight. A flagged Retrigger on an Active+unacked condition with the same
  message and severity, or a flagged Clear on an inactive condition, writes no /alerts row.
- NativeAlarmProjector reports the history rows per transition; ForwardNativeAlarm writes one
  row per entry. Unflagged sources (FOCAS/MTConnect/AbCip, live Galaxy) are unchanged.
- Tests: projector rules each with a must-write control; OpcUaClient transition table;
  Galaxy feed/driver mapping; Galaxy + OpcUaClient end-to-end through DriverHostActor.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 23:35:50 -04:00
parent ca17d2cf71
commit b39bb6794d
15 changed files with 973 additions and 81 deletions
@@ -233,16 +233,16 @@ public class NativeAlarmProjectorTests
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool first).Active.ShouldBeTrue();
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool restatement);
restatement.ShouldBeTrue();
restated.Active.ShouldBeTrue();
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out bool retrigger);
retrigger.ShouldBeFalse();
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
@@ -259,20 +259,209 @@ public class NativeAlarmProjectorTests
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
sut.MarkAcknowledged("n1");
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out bool r1);
r1.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out bool r2);
r2.ShouldBeFalse("a different alarm is not a re-statement");
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
}
// ---------------------------------------------------------------------------------------------------------
// Gitea #656 — ActiveAcknowledged + IsRestatement. Every "no row" test is paired with a control that MUST write.
// ---------------------------------------------------------------------------------------------------------
/// <summary>#656 claim 1 — an acked active alarm on a condition the host has never seen (a cold start) projects
/// ACTIVE+acked in one update, with an Activated row then an Acknowledged row. Pre-fix Galaxy sent Acknowledge,
/// which projected inactive+acked: an alarm active in AVEVA was invisible to OPC UA clients.</summary>
[Fact]
public void ActiveAcknowledged_on_an_unknown_condition_projects_active_and_acked_with_Activated_then_Acknowledged_rows()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 — the wider half of claim 1: a KNOWN but inactive prior (cleared before a gateway outage, then went
/// active and acked during it) must also activate.</summary>
[Fact]
public void ActiveAcknowledged_on_an_inactive_known_prior_activates()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Active.ShouldBeFalse();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
[Fact]
public void ActiveAcknowledged_on_an_active_unacked_condition_writes_only_an_Acknowledged_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
rows.ShouldBe([AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 related item 2 — a Galaxy reconnect replays an ActiveAcked entry the host already shows; that
/// is no row. Control: the same entry with a different message is a different alarm and writes both rows.</summary>
[Fact]
public void ActiveAcknowledged_on_an_active_acked_condition_with_the_same_message_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"));
var same = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"), out IReadOnlyList<AlarmTransitionKind> sameRows);
sameRows.ShouldBeEmpty();
same.Active.ShouldBeTrue();
same.Acknowledged.ShouldBeTrue();
// Control: a different message is a different alarm.
sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hihi"), out IReadOnlyList<AlarmTransitionKind> otherRows);
otherRows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>#656 claim 2 — a re-stated Retrigger (Galaxy snapshot replay, OpcUaClient first sight) on a condition
/// already Active+unacked with the same message and severity writes no row; the node does not change.</summary>
[Fact]
public void A_restated_Retrigger_on_an_active_unacked_condition_with_same_message_and_severity_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> first);
first.ShouldBe([AlarmTransitionKind.Retrigger], "the first statement on an unknown condition is a real activation");
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
}
/// <summary>Control (a): an UNFLAGGED Retrigger — a live Galaxy re-occurrence, a FOCAS key swap — with the same
/// everything is a genuine re-occurrence and keeps its row. FOCAS/MTConnect/AbCip behaviour is unchanged.</summary>
[Fact]
public void An_unflagged_Retrigger_on_an_active_unacked_condition_still_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (b): a restated Retrigger whose SEVERITY changed (MTConnect Warning→Fault with a detail text
/// keeps the message) is a new statement.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_severity_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.Medium, restated: true));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.High, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (b'): the exact-severity path (<see cref="AlarmEventArgs.OpcUaSeverity"/>) is compared too.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_exact_severity_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 500 });
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 800 },
out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (c): a restated Retrigger with a different message is a different alarm.</summary>
[Fact]
public void A_restated_Retrigger_with_a_different_message_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "a", restated: true));
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "b", restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>Control (d): a restated Retrigger on an Active+ACKED condition un-acks (upstream's ack state is
/// authoritative), so the node changes and the row is written.</summary>
[Fact]
public void A_restated_Retrigger_on_an_active_acked_condition_unacks_and_writes_an_Activated_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
sut.MarkAcknowledged("n1");
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
snap.Acknowledged.ShouldBeFalse();
rows.ShouldBe([AlarmTransitionKind.Retrigger]);
}
/// <summary>#656 related item 3 — a restated Clear (Galaxy Inactive snapshot entry) on a condition that is already
/// inactive writes no row; the unflagged control and a restated Clear of an ACTIVE condition both write one.</summary>
[Fact]
public void A_restated_Clear_on_an_inactive_condition_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("unknown", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> unknownRows);
unknownRows.ShouldBeEmpty();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
sut.Project("n1", Evt(AlarmTransitionKind.Clear));
sut.Project("n1", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
// Controls: unflagged on inactive keeps its row (unchanged behaviour); restated on ACTIVE is a real clear.
sut.Project("n1", Evt(AlarmTransitionKind.Clear), out IReadOnlyList<AlarmTransitionKind> unflagged);
unflagged.ShouldBe([AlarmTransitionKind.Clear]);
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
sut.Project("n2", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList<AlarmTransitionKind> active);
active.ShouldBe([AlarmTransitionKind.Clear]);
}
/// <summary>#656 — the existing per-kind rows for unflagged sources are exactly one row of the event's own kind
/// (what the host wrote before, byte-identical for FOCAS/MTConnect/AbCip).</summary>
[Theory]
[InlineData(AlarmTransitionKind.Raise)]
[InlineData(AlarmTransitionKind.Retrigger)]
[InlineData(AlarmTransitionKind.Acknowledge)]
[InlineData(AlarmTransitionKind.Clear)]
[InlineData(AlarmTransitionKind.Unspecified)]
public void An_unflagged_transition_writes_one_row_of_its_own_kind(AlarmTransitionKind kind)
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(kind), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBe([kind]);
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
string msg = "m")
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
string msg = "m",
bool restated = false)
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind, IsRestatement: restated);
private sealed class H : IAlarmSubscriptionHandle
{