Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
T
Joseph Doherty 48a80b5962 fix(alarms): atomic InactiveUnacknowledged + flagged Clear/Acknowledge first sight for OpcUaClient (#657)
After an OpcUaClient respawn an upstream inactive+unacked condition went out as
Retrigger+Clear (duplicate Activated+Cleared rows, Active flicker), and a
first-sight inactive+acked one as Unspecified (an Activated row for an inactive
alarm; a retained-Active host condition never cleared).

- New AlarmTransitionKind.InactiveUnacknowledged: one projection, rows only for
  what changed (Activated+Cleared from acked, Cleared from active+unacked, none
  when already inactive+unacked).
- OpcUaClient uses it for moves into inactive+unacked; first sight of an
  inactive condition is InactiveUnacknowledged or Clear+Acknowledge, flagged.
- Projector: a flagged Acknowledge on an already-acked condition writes no row.
- E2E tests through DriverHostActor.ForwardNativeAlarm (red-proven).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 06:08:39 -04:00

545 lines
24 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public class NativeAlarmProjectorTests
{
[Fact]
public void Raise_projects_active_unacked_with_constant_fields_and_mapped_severity()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "boom"));
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
snap.Confirmed.ShouldBeTrue();
snap.Enabled.ShouldBeTrue();
snap.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
snap.Severity.ShouldBe((ushort)700);
snap.Message.ShouldBe("boom");
}
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
/// one's (which then starts from the default inactive + acked).</summary>
[Fact]
public void RetainOnly_keeps_deployed_conditions_and_forgets_removed_ones()
{
var sut = new NativeAlarmProjector();
sut.Project("kept", Evt(AlarmTransitionKind.Raise));
sut.Project("removed", Evt(AlarmTransitionKind.Raise));
sut.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "kept" });
sut.Project("kept", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeTrue();
sut.Project("removed", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeFalse();
}
[Fact]
public void Retrigger_projects_active_unacked()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
}
[Fact]
public void Acknowledge_after_raise_acks_and_keeps_active()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
}
[Fact]
public void Clear_after_raise_deactivates_and_keeps_prior_ack()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeFalse(); // preserved prior (unacked) ack state
}
[Fact]
public void Clear_after_acknowledge_deactivates_and_keeps_acked()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); // active, acked
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeTrue(); // preserved prior (acked) ack state
}
[Fact]
public void Unspecified_carries_prior_active_and_ack_state()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified));
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
}
[Fact]
public void Cold_state_defaults_to_inactive_acked()
{
var sut = new NativeAlarmProjector();
// Acknowledge on a never-seen node: prior Active defaults false, ack set true.
var snap = sut.Project("nNew", Evt(AlarmTransitionKind.Acknowledge));
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeTrue();
}
[Fact]
public void Per_node_state_is_isolated()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active
// n2 is cold; a Clear on it must stay inactive and not borrow n1's active state.
var n2 = sut.Project("n2", Evt(AlarmTransitionKind.Clear));
n2.Active.ShouldBeFalse();
// n1 is unaffected by the n2 transition.
var n1 = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
n1.Active.ShouldBeTrue();
}
[Theory]
[InlineData(AlarmSeverity.Low, (ushort)200)]
[InlineData(AlarmSeverity.Medium, (ushort)500)]
[InlineData(AlarmSeverity.High, (ushort)700)]
[InlineData(AlarmSeverity.Critical, (ushort)900)]
public void Severity_buckets_map_to_opcua_scale(AlarmSeverity sev, ushort expected)
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, sev));
snap.Severity.ShouldBe(expected);
}
[Fact]
public void Severity_and_message_always_come_from_the_event()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "first"));
// A later Clear carries its own severity + message; the snapshot reflects the event, not prior.
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear, AlarmSeverity.Low, "second"));
snap.Severity.ShouldBe((ushort)200);
snap.Message.ShouldBe("second");
}
[Fact]
public void Clear_resets_tracked_state()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active, unacked
sut.Clear();
// After Clear() the node is cold again: an Acknowledge sees prior Active=false.
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeTrue();
}
// #477 — native transitions carry Good quality; comms-loss quality is applied out-of-band via the
// dedicated WriteAlarmQuality path (connectivity → DriverHostActor), NOT through the projector, so the
// projector stays quality-agnostic and its transition snapshots default to Good.
[Fact]
public void Transition_projection_defaults_to_good_quality()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
}
// Gitea #626 — a source with a finer severity scale than the four buckets (MTConnect Fault = 800) sets
// OpcUaSeverity, which supersedes the bucket on the projected condition. Clamped to the Part 9 1..1000.
[Theory]
[InlineData(800, 800)]
[InlineData(500, 500)]
[InlineData(0, 1)]
[InlineData(5000, 1000)]
public void Explicit_opcua_severity_supersedes_the_bucket(int requested, int projected)
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High) with { OpcUaSeverity = requested });
snap.Severity.ShouldBe((ushort)projected);
}
// Gitea #626 — a per-condition source quality (MTConnect Unavailable) rides the snapshot's StatusCode, the
// same Quality child #477's connectivity annotation writes.
[Fact]
public void Supplied_quality_is_projected_onto_the_snapshot()
{
var sut = new NativeAlarmProjector();
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
// Gitea #626 — a quality-only annotation (Unspecified kind + quality) must not move Active/Acked: an
// active, unacknowledged condition stays exactly that while only its Quality changes.
[Fact]
public void Quality_only_annotation_keeps_the_prior_alarm_state()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeFalse();
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
}
/// <summary>
/// Gitea #647 review — a Raise on a condition that is ALREADY active is a re-statement (a respawned driver
/// re-reading a still-active alarm it never told the server about): it keeps the acknowledgement and is
/// flagged so the host writes no duplicate "Activated" row. Only a Retrigger (or inactive → active) un-acks.
/// </summary>
[Fact]
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 bool first).Active.ShouldBeTrue();
first.ShouldBeFalse("inactive → active is a real transition");
sut.MarkAcknowledged("n1");
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 bool retrigger);
retrigger.ShouldBeFalse();
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
}
/// <summary>
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
/// </summary>
[Fact]
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
{
var sut = new NativeAlarmProjector();
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 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 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]);
}
/// <summary>hw4 (AbCip ALMA) — an Unspecified + IsRestatement update on an ACTIVE condition moves the text/severity,
/// keeps active/acked, and writes NO row; an unflagged Unspecified keeps its historical "Activated" row.</summary>
[Fact]
public void A_flagged_Unspecified_update_moves_text_and_severity_without_a_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.Critical, "HH alarm"), out IReadOnlyList<AlarmTransitionKind> _);
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> _);
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm", restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
snap.Message.ShouldBe("H alarm");
snap.Severity.ShouldBe((ushort)700);
// Control: the same update WITHOUT the flag still writes its row (unchanged behaviour for other sources).
sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm"), out IReadOnlyList<AlarmTransitionKind> legacy);
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
}
/// <summary>
/// Gitea #657 — InactiveUnacknowledged projects inactive+unacked in ONE update from every prior, and writes rows
/// only for what changed: "Activated"+"Cleared" from an acknowledged prior (an un-ack needs a new occurrence),
/// "Cleared" from Active+unacked, nothing when the condition already shows inactive+unacked. The flag does not
/// change it — the rows already describe only real changes.
/// </summary>
[Theory]
[InlineData(false, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear })]
[InlineData(true, false, new[] { AlarmTransitionKind.Clear })]
[InlineData(false, false, new AlarmTransitionKind[0])]
public void InactiveUnacknowledged_projects_inactive_unacked_with_rows_only_for_what_changed(
bool priorActive, bool priorAcked, AlarmTransitionKind[] expected)
{
foreach (var restated in new[] { false, true })
{
var sut = new NativeAlarmProjector();
if (priorActive) sut.Project("n1", Evt(AlarmTransitionKind.Raise));
if (priorActive && priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
if (!priorActive && !priorAcked) sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged));
var snap = sut.Project("n1", Evt(AlarmTransitionKind.InactiveUnacknowledged, restated: restated),
out IReadOnlyList<AlarmTransitionKind> rows);
snap.Active.ShouldBeFalse();
snap.Acknowledged.ShouldBeFalse();
rows.ShouldBe(expected);
}
}
/// <summary>
/// Gitea #657 — a flagged Acknowledge on an already-acknowledged condition (OpcUaClient's first-sight Clear +
/// Acknowledge of an inactive+acked condition) writes no row; the unflagged control and a flagged Acknowledge
/// of an UNACKED condition both write one.
/// </summary>
[Fact]
public void A_restated_Acknowledge_on_an_acked_condition_writes_no_row()
{
var sut = new NativeAlarmProjector();
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> cold);
cold.ShouldBeEmpty();
sut.Project("cold", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> unflagged);
unflagged.ShouldBe([AlarmTransitionKind.Acknowledge]);
sut.Project("n2", Evt(AlarmTransitionKind.Raise));
var snap = sut.Project("n2", Evt(AlarmTransitionKind.Acknowledge, restated: true), out IReadOnlyList<AlarmTransitionKind> real);
real.ShouldBe([AlarmTransitionKind.Acknowledge]);
snap.Acknowledged.ShouldBeTrue();
snap.Active.ShouldBeTrue();
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
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
{
public string DiagnosticId => "t";
}
}