fix(mtconnect): review rulings for CONDITION alarms (#626)

- Unavailable keeps an ACTIVE condition active: quality-only BadNoCommunication; on data
  return same statement => quality-only Good, Normal => Clear, different => Retrigger.
- A changed alarm reference set (alarm-bearing delta = full rebuild) clears the whole
  last-told record so every active condition is re-asserted; the same set keeps it.
- Quality-only events go through the host's AlarmQualityUpdate path, never a projected
  snapshot (which could ack a retained unacked condition or rewrite message/severity).
- Deterministic tie-break between equal-level conditions (ordinal nativeCode, then type).
- Dedup key is level+nativeCode+nativeSeverity+qualifier; a text-only change updates nothing.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 15:38:52 -04:00
parent 6a5f5c5a28
commit 9c8e3da5da
7 changed files with 382 additions and 112 deletions
@@ -20,6 +20,7 @@ public sealed class MTConnectAlarmSourceTests
private const string ConditionRawPath = "Plant/MT/dev1/SystemCond";
private const string ConditionValueRawPath = "Plant/MT/dev1/SystemCondText";
private const string ConditionId = "dev1_system_cond";
private const string OtherConditionRawPath = "Plant/MT/dev1/OtherCond";
private const long FixtureInstanceId = 1655000000L;
private const long RestartedInstanceId = 1655999999L;
@@ -39,6 +40,7 @@ public sealed class MTConnectAlarmSourceTests
// must not disturb the existing value surface.
new RawTagEntry(ConditionRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
new RawTagEntry(ConditionValueRawPath, $$"""{"address":"{{ConditionId}}"}""", false, "dev1"),
new RawTagEntry(OtherConditionRawPath, """{"address":"dev1_other_cond"}""", false, "dev1"),
],
};
@@ -125,6 +127,55 @@ public sealed class MTConnectAlarmSourceTests
await driver.ShutdownAsync(Ct);
}
[Fact]
public async Task An_ack_survives_an_adapter_unavailable_blip()
{
// A cppagent marks an adapter's items UNAVAILABLE when the adapter socket drops. The Fault must stay
// active (no Clear/Raise pair, which would reset the operator's ack); only its quality moves.
var (driver, agent, events) = await AlarmDriverAsync();
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
await agent.PumpAsync(Chunk(109, 110, Cond("UNAVAILABLE"))).WaitAsync(Watchdog, Ct);
await agent.PumpAsync(Chunk(110, 111, Cond("Fault", "E17", "overtemp"))).WaitAsync(Watchdog, Ct);
var seen = events.ToArray();
seen.Select(e => e.Kind).ShouldBe(
[AlarmTransitionKind.Raise, AlarmTransitionKind.Unspecified, AlarmTransitionKind.Unspecified]);
seen[1].IsQualityOnly.ShouldBeTrue();
seen[1].QualityStatusCode.ShouldBe(0x80310000u);
seen[2].IsQualityOnly.ShouldBeTrue();
seen[2].QualityStatusCode.ShouldBe(0x00000000u);
await driver.ShutdownAsync(Ct);
}
[Fact]
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
{
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
var (driver, agent, events) = await AlarmDriverAsync();
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
events.Last().ConditionId.ShouldBe(ConditionRawPath);
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
// Positive control: the next real transition is the very next event.
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
events.Select(e => e.Kind).ShouldBe(
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
"the same set (a reconnect) must re-raise nothing");
await driver.ShutdownAsync(Ct);
}
[Fact]
public async Task A_resubscribe_with_an_unchanged_active_state_raises_nothing()
{