fix(abcip): ALMA review round 2 - IsRestatement contract docs, ALMD no-InAlarm warning, un-ack retrigger

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 04:30:56 -04:00
parent 83d8de303c
commit 5d7c53bb19
8 changed files with 105 additions and 10 deletions
@@ -196,6 +196,44 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>hw4 F2 — an ALMD that declared members but no InAlarm (a pre-hw4 InFaulted config) would read
/// BadNodeIdUnknown forever; it logs ONE Warning per tag instead of staying silent.</summary>
[Fact]
public async Task An_almd_without_a_declared_InAlarm_warns_once()
{
var legacy = new AbCipTagDefinition("Legacy", Device, "Legacy", AbCipDataType.Structure, Members:
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt)]);
var log = new CapturingLogger();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(legacy),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", new FakeAbCipTagFactory(), logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
var h1 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
var h2 = await drv.SubscribeAlarmsAsync(["Legacy"], CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(h1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(h2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
log.Warnings.Count(m => m.Contains("Legacy") && m.Contains("InAlarm")).ShouldBe(1);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbCipDriver>
{
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) lock (Warnings) Warnings.Add(formatter(state, exception));
}
}
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
@@ -254,6 +254,25 @@ public sealed class AbCipAlmaProjectionTests
"only the genuine PLC ack — the unacked HH excursion must keep the condition unacknowledged");
}
/// <summary>F3 — fully acked, then a declared *Acked goes false with the SAME limits active (a limit that left and re-entered
/// between polls): the PLC says unacknowledged, so it is told as a Retrigger.</summary>
[Fact]
public async Task An_ack_falling_with_an_unchanged_active_set_retriggers()
{
var r = await StartAsync(x =>
{
x.Set("HInAlarm", true);
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true);
});
await Next(r, x => x.Kind == AlarmTransitionKind.ActiveAcknowledged);
r.Set("HAcked", false);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
await Settled(r);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} H alarm");
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Retrigger).ShouldBe(1);
}
/// <summary>An alarm already active AND acked when first seen is told atomically, never Raise-then-Acknowledge.</summary>
[Fact]
public async Task An_already_acked_active_alarm_is_active_acknowledged()