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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user