Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:26:47 -04:00
23 changed files with 1060 additions and 50 deletions
@@ -189,6 +189,80 @@ public sealed class AbCipAlarmProjectionTests
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
// it had routed.
AlarmEventArgs raised, cleared;
lock (events)
{
raised = events.First(e => e.Message.Contains("raised"));
cleared = events.First(e => e.Message.Contains("cleared"));
}
raised.ConditionId.ShouldBe("HighTemp");
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
cleared.ConditionId.ShouldBe("HighTemp");
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
}
/// <summary>
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
/// </summary>
[Fact]
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
// A reconnect / alarm-set change: the same ref, still faulted.
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
/// (inactive) when it came back, so it is raised again.</summary>
[Fact]
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForEventAsync(raises, _ => raises.Count >= 2);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
@@ -151,27 +151,31 @@ public sealed class MTConnectAlarmSourceTests
}
[Fact]
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
{
// 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.
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
// was materialised fresh and must be told again.
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");
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
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");
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
await driver.ShutdownAsync(Ct);
}