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
This commit is contained in:
Joseph Doherty
2026-10-09 06:08:39 -04:00
parent 98c00904e7
commit 48a80b5962
10 changed files with 354 additions and 27 deletions
@@ -227,6 +227,10 @@ public sealed class OpcUaClientAlarmTests
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
// Gitea #657: inactive+unacked is ONE atomic InactiveUnacknowledged (never an Active flicker), except from
// Active+unacked, where a plain Clear already lands there.
[InlineData(false, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
[InlineData(true, true, false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
@@ -242,13 +246,34 @@ public sealed class OpcUaClientAlarmTests
/// </summary>
[Theory]
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
[InlineData(false, false, new[] { AlarmTransitionKind.InactiveUnacknowledged })]
[InlineData(true, true, new[] { AlarmTransitionKind.ActiveAcknowledged })]
[InlineData(false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
}
/// <summary>
/// Gitea #657 — on first sight the HOST may hold any state (it retains a still-deployed condition across a
/// respawn), so the first-sight kinds must land on the upstream's state from EVERY host state. Pre-fix an
/// inactive+acked target emitted nothing state-bearing, leaving a retained-Active host condition Active.
/// </summary>
[Fact]
public void First_sight_kinds_reach_the_target_from_every_host_state()
{
bool[] b = [false, true];
foreach (var ha in b) foreach (var hk in b) foreach (var ta in b) foreach (var tk in b)
{
var kinds = OpcUaClientDriver.AlarmTransitions((false, true), (ta, tk), priorKnown: false);
var s = (ha, hk);
foreach (var k in kinds) s = Simulate(s, k);
s.ShouldBe((ta, tk), $"host ({ha},{hk}) -> upstream ({ta},{tk}) via [{string.Join(",", kinds)}]");
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
kinds.ShouldNotContain(AlarmTransitionKind.Raise, "a first sight must not keep a stale host ack");
}
}
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
@@ -256,6 +281,7 @@ public sealed class OpcUaClientAlarmTests
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.ActiveAcknowledged => (true, true),
AlarmTransitionKind.InactiveUnacknowledged => (false, false),
AlarmTransitionKind.Acknowledge => (s.Active, true),
AlarmTransitionKind.Clear => (false, s.Acked),
_ => s,
@@ -414,6 +440,32 @@ public sealed class OpcUaClientAlarmTests
]);
}
/// <summary>
/// Gitea #657 — first sight of an INACTIVE upstream condition: inactive+unacked is ONE flagged
/// InactiveUnacknowledged (pre-fix a Retrigger then a Clear — clients saw it Active again); inactive+acked is
/// a flagged Clear then a flagged Acknowledge (pre-fix an Unspecified the host wrote as "Activated").
/// </summary>
[Fact]
public void First_sight_of_an_inactive_condition_is_restated_atomically()
{
using var unacked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657a");
var seenA = Record(unacked);
unacked.RouteAlarmEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle());
seenA.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([(AlarmTransitionKind.InactiveUnacknowledged, true)]);
using var acked = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-657b");
var seenB = Record(acked);
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
seenB.Select(e => (e.Kind, e.IsRestatement)).ShouldBe([
(AlarmTransitionKind.Clear, true),
(AlarmTransitionKind.Acknowledge, true),
]);
// Positive control: once known, the identical refresh copy emits nothing.
acked.RouteAlarmEvent(ConditionFields(false, true), TripIndex, new FakeAlarmHandle());
seenB.Count.ShouldBe(2);
}
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
[Fact]
public void The_exact_upstream_severity_is_carried()