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