fix(abcip): ALMA review round 1 + ALMD polls InAlarm (not InFaulted)

M1 drop-out is a no-row Unspecified+IsRestatement update (projector case); M2 ack judged over every declared
limit; L1 ActiveAcknowledged for an already-acked new limit; L2 Bad Acked/severity carried/skipped; L3 host
end-to-end test; L4 BOOL member-by-member tests + mixed ALMD/ALMA; L5 detection requires no plain Acked.
ALMD active bit is InAlarm per Rockwell's ALMD reference; InFaulted is input health.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 04:22:45 -04:00
parent b3838fcce9
commit 83d8de303c
13 changed files with 641 additions and 161 deletions
@@ -8,16 +8,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
/// <summary>
/// Golden-box-tier ALMD alarm projection tests against Logix Emulate.
/// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage
/// (<c>AbCipAlarmProjectionTests</c> with faked InFaulted sequences) to end-to-end
/// (<c>AbCipAlarmProjectionTests</c> with faked InAlarm sequences) to end-to-end
/// wire-level coverage — Emulate runs the real ALMD instruction, with real
/// rising-edge semantics on <c>InFaulted</c> + <c>Ack</c>, so the driver's poll-based
/// rising-edge semantics on <c>InAlarm</c> + <c>Ack</c>, so the driver's poll-based
/// projection gets validated against the actual behaviour shops running FT View see.
/// </summary>
/// <remarks>
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>):</para>
/// <list type="bullet">
/// <item>Controller-scope ALMD tag <c>HighTempAlarm</c> — a standard ALMD instruction
/// with default member set (<c>In</c>, <c>InFaulted</c>, <c>Acked</c>,
/// with default member set (<c>In</c>, <c>InAlarm</c>, <c>Acked</c>,
/// <c>Severity</c>, <c>Cfg_ProgTime</c>, …).</item>
/// <item>A periodic task that drives <c>HighTempAlarm.In</c> false→true→false at a
/// cadence the operator can script via a one-shot routine (e.g. a
@@ -64,7 +64,7 @@ public sealed class AbCipEmulateAlmdTests
TagPath: "HighTempAlarm",
DataType: AbCipDataType.Structure,
Members: [
new AbCipStructureMember("InFaulted", AbCipDataType.DInt),
new AbCipStructureMember("InAlarm", AbCipDataType.DInt),
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
@@ -92,7 +92,7 @@ public sealed class AbCipEmulateAlmdTests
["HighTempAlarm"], TestContext.Current.CancellationToken);
// Pulse the input bit the ladder watches, then wait for the driver's poll loop
// to see InFaulted rise + fire the raise event.
// to see InAlarm rise + fire the raise event.
_ = await drv.WriteAsync(
[new WriteRequest("SimulateAlarm", true)],
TestContext.Current.CancellationToken);
@@ -16,10 +16,11 @@ public sealed class AbCipAlarmProjectionTests
private static AbCipTagDefinition AlmdTag(string name) => new(
name, Device, name, AbCipDataType.Structure, Members:
[
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("InAlarm", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // input-health flag — NOT the alarm
]);
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
@@ -75,7 +76,7 @@ public sealed class AbCipAlarmProjectionTests
// subscription had started would have read (and so created) the ALMD runtime by now.
await PolledThreeTimesWithTheFlagOnAsync();
factory.Tags.ShouldNotContainKey("HighTemp");
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
factory.Tags.ShouldNotContainKey("HighTemp.InAlarm");
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
@@ -96,11 +97,11 @@ public sealed class AbCipAlarmProjectionTests
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
// gets created + polled. Set InFaulted offset-value to 0 first (clear), let polls seed the
// gets created + polled. Set InAlarm offset-value to 0 first (clear), let polls seed the
// "last-seen false" state, then flip to 1 (fault) + wait for the raise event.
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InAlarm+Acked)
await WaitForMoreReadsAsync(factory, "HighTemp", 2);
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
@@ -136,11 +137,11 @@ public sealed class AbCipAlarmProjectionTests
if (e.Message.Contains("raised")) lock (raises) raises.Add(e);
};
// First subscribe creates + starts polling the HighTemp runtime. Seed InFaulted=false +
// First subscribe creates + starts polling the HighTemp runtime. Seed InAlarm=false +
// a severity so the loop's baseline settles on "not faulted".
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8
await WaitForMoreReadsAsync(factory, "HighTemp", 2); // sub1's loop has seeded its baseline
@@ -166,6 +167,35 @@ public sealed class AbCipAlarmProjectionTests
}
}
/// <summary>
/// hw4 — ALMD's alarm-active output is <c>InAlarm</c> (Rockwell ALMD reference); <c>InFaulted</c> (Status.1) is
/// the INPUT-health flag. The projection used to poll InFaulted, so a live ALMD would raise on an input fault
/// and never on the alarm itself.
/// </summary>
[Fact]
public async Task An_input_fault_is_not_an_alarm_and_InAlarm_is()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InAlarm clear
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[16] = 1; // InFaulted set: input health bad, not an alarm
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
lock (events) events.ShouldBeEmpty("an input fault (InFaulted) is not an alarm");
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // the alarm condition
await WaitForEventAsync(events, e => e.Kind == AlarmTransitionKind.Raise);
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <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()
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Gitea #611 item 1 — ALMA (<c>ALARM_ANALOG</c>) projection. Driven through fake tag runtimes only:
/// ab_server has no alarm instructions, and Z35162 has no ALMA/ALMD, so there is no wire-level gate.
/// Status members are declared BOOL, as on a real controller, so every member is read through its own runtime
/// (the production read path) rather than the whole-UDT offset fast path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlmaProjectionTests
@@ -15,41 +17,35 @@ public sealed class AbCipAlmaProjectionTests
private const string Device = "ab://10.0.0.5/1,0";
private const string Tag = "HighPress";
// Declaration order fixes the fake's byte offsets (DINT stride 4).
private static readonly string[] Layout =
private static readonly string[] Bits =
[
"HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "ROCPosInAlarm",
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked",
"HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity",
"HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked", "OperAckAll",
];
private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity", "ROCPosSeverity"];
private static AbCipTagDefinition AlmaTag() => new(
Tag, Device, Tag, AbCipDataType.Structure,
Members: [.. Layout.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt)),
new AbCipStructureMember("OperAckAll", AbCipDataType.DInt)]);
private static AbCipTagDefinition AlmaTag(string name = Tag) => new(
name, Device, name, AbCipDataType.Structure,
Members: [.. Bits.Select(n => new AbCipStructureMember(n, AbCipDataType.Bool)),
.. Sevs.Select(n => new AbCipStructureMember(n, AbCipDataType.DInt))]);
private static AbCipDriverOptions Options() => new()
private static AbCipTagDefinition AlmdTag(string name) => new(
name, Device, name, AbCipDataType.Structure,
Members: [new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool),
new AbCipStructureMember("Severity", AbCipDataType.DInt)]);
private static AbCipDriverOptions Options(params AbCipTagDefinition[] tags) => new()
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(AlmaTag()),
RawTags = AbCipRawTags.From(tags),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
EnableDeclarationOnlyUdtGrouping = true,
};
private static void Set(FakeAbCipTag t, string member, int v) => t.ValuesByOffset[Array.IndexOf(Layout, member) * 4] = v;
/// <summary>Seeds a quiet, ack-required ALMA: severities set, nothing in alarm, nothing acked.</summary>
private static void Quiet(FakeAbCipTag t)
{
foreach (var n in Layout) Set(t, n, 0);
Set(t, "HHSeverity", 900); Set(t, "HSeverity", 600); Set(t, "LSeverity", 300);
Set(t, "LLSeverity", 800); Set(t, "ROCPosSeverity", 400);
}
private sealed record Rig(AbCipDriver Drv, FakeAbCipTagFactory Factory, List<AlarmEventArgs> Events, IAlarmSubscriptionHandle Handle)
{
public FakeAbCipTag T => Factory.Tags[Tag];
public void Set(string member, object v, string tag = Tag) => Factory.Tags[$"{tag}.{member}"].Value = v;
public FakeAbCipTag Probe => Factory.Tags[$"{Tag}.HHInAlarm"];
public async Task StopAsync()
{
await Drv.UnsubscribeAlarmsAsync(Handle, CancellationToken.None);
@@ -57,18 +53,21 @@ public sealed class AbCipAlmaProjectionTests
}
}
private static async Task<Rig> StartAsync(Action<FakeAbCipTag>? seed = null)
/// <summary>Starts a quiet ALMA (severities set, nothing in alarm, nothing acked) and applies <paramref name="seed"/>.</summary>
private static async Task<Rig> StartAsync(Action<Rig>? seed = null)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(), "drv-1", factory);
var drv = new AbCipDriver(Options(AlmaTag()), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
await WaitFor(() => factory.Tags.ContainsKey(Tag));
var rig = new Rig(drv, factory, events, handle);
Quiet(rig.T);
seed?.Invoke(rig.T);
await WaitFor(() => Bits.Take(10).Concat(Sevs).All(m => factory.Tags.ContainsKey($"{Tag}.{m}")));
foreach (var b in Bits.Take(10)) rig.Set(b, false);
rig.Set("HHSeverity", 900); rig.Set("HSeverity", 600); rig.Set("LSeverity", 300);
rig.Set("LLSeverity", 800); rig.Set("ROCPosSeverity", 400);
seed?.Invoke(rig);
return rig;
}
@@ -79,26 +78,36 @@ public sealed class AbCipAlmaProjectionTests
throw new TimeoutException("condition never met");
}
private static Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match) =>
WaitFor(() => { lock (r.Events) return r.Events.Any(match); })
.ContinueWith(_ => { lock (r.Events) return r.Events.First(match); });
private static async Task Settled(Rig r)
private static async Task<AlarmEventArgs> Next(Rig r, Func<AlarmEventArgs, bool> match)
{
var target = r.T.ReadCount + 6;
await WaitFor(() => r.T.ReadCount >= target);
await WaitFor(() => { lock (r.Events) return r.Events.Any(match); });
lock (r.Events) return r.Events.First(match);
}
/// <summary>The detector flags the six-limit signature and not an ALMD or a plain UDT.</summary>
/// <summary>Processed-count wait: six more polls of the shared member runtime.</summary>
private static async Task Settled(Rig r)
{
var target = r.Probe.ReadCount + 6;
await WaitFor(() => r.Probe.ReadCount >= target);
}
private static List<AlarmEventArgs> Snapshot(Rig r) { lock (r.Events) return [.. r.Events]; }
/// <summary>Detector: ALMA by per-limit bits and no plain Acked; ALMD-shaped / plain UDTs are not ALMA.</summary>
[Fact]
public void Detector_Flags_Alma_And_Not_Almd()
public void Detector_Flags_Alma_And_Not_Almd_Or_User_Udts()
{
AbCipAlarmDetector.IsAlma(AlmaTag()).ShouldBeTrue();
var almd = new AbCipTagDefinition("A", Device, "A", AbCipDataType.Structure, Members:
[new AbCipStructureMember("InFaulted", AbCipDataType.DInt), new AbCipStructureMember("Acked", AbCipDataType.DInt)]);
AbCipAlarmDetector.IsAlma(almd).ShouldBeFalse();
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
AbCipAlarmDetector.IsAlma(AlmdTag("A")).ShouldBeFalse();
AbCipAlarmDetector.IsAlmd(AlmdTag("A")).ShouldBeTrue();
// A user UDT that is ALMD-shaped but happens to name an "HInAlarm" stays ALMD.
var hybrid = new AbCipTagDefinition("U", Device, "U", AbCipDataType.Structure, Members:
[new AbCipStructureMember("InAlarm", AbCipDataType.Bool), new AbCipStructureMember("Acked", AbCipDataType.Bool),
new AbCipStructureMember("HInAlarm", AbCipDataType.Bool)]);
AbCipAlarmDetector.IsAlma(hybrid).ShouldBeFalse();
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt)).ShouldBeFalse();
// An ALMA authored WITHOUT members cannot be told apart from an ALMD: it falls to the ALMD path.
AbCipAlarmDetector.IsAlma(new AbCipTagDefinition("N", Device, "N", AbCipDataType.Structure)).ShouldBeFalse();
}
/// <summary>A limit entering alarm raises one ALMA condition keyed on the RawPath, naming the limit, with the limit's severity.</summary>
@@ -107,10 +116,9 @@ public sealed class AbCipAlmaProjectionTests
{
var r = await StartAsync();
await Settled(r);
Set(r.T, "HInAlarm", 1);
r.Set("HInAlarm", true);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.ConditionId.ShouldBe(Tag);
e.SourceNodeId.ShouldBe(Tag);
e.AlarmType.ShouldBe("ALMA");
@@ -123,7 +131,7 @@ public sealed class AbCipAlmaProjectionTests
[Fact]
public async Task The_most_severe_active_limit_wins()
{
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "HHInAlarm", 1); Set(t, "ROCPosInAlarm", 1); });
var r = await StartAsync(x => { x.Set("LInAlarm", true); x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); });
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
@@ -134,7 +142,7 @@ public sealed class AbCipAlmaProjectionTests
[Fact]
public async Task A_rate_of_change_limit_is_named_with_its_sign()
{
var r = await StartAsync(t => Set(t, "ROCPosInAlarm", 1));
var r = await StartAsync(x => x.Set("ROCPosInAlarm", true));
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} ROC+ alarm");
@@ -144,103 +152,195 @@ public sealed class AbCipAlmaProjectionTests
[Fact]
public async Task All_limits_clearing_fires_one_clear()
{
var r = await StartAsync(t => Set(t, "LLInAlarm", 1));
var r = await StartAsync(x => x.Set("LLInAlarm", true));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "LLInAlarm", 0);
r.Set("LLInAlarm", false);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Clear);
await Settled(r);
await r.StopAsync();
e.ConditionId.ShouldBe(Tag);
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Clear).ShouldBe(1);
}
/// <summary>A newly entered limit while already active is a Retrigger (a new unacknowledged occurrence), not a second Raise.</summary>
/// <summary>A newly entered (unacked) limit while active is a Retrigger, not a second Raise.</summary>
[Fact]
public async Task A_newly_entered_limit_while_active_retriggers()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
var r = await StartAsync(x => x.Set("HInAlarm", true));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HHInAlarm", 1);
r.Set("HHInAlarm", true);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
await Settled(r);
await r.StopAsync();
e.Message.ShouldBe($"ALMA {Tag} HH alarm");
e.OpcUaSeverity.ShouldBe(900);
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
}
/// <summary>A limit dropping out while another stays active updates message/severity without a Raise/Retrigger/Clear.</summary>
/// <summary>L1 — a limit entering ALREADY acked (AckRequired false) is atomic ActiveAcknowledged, never Retrigger+Acknowledge.</summary>
[Fact]
public async Task A_limit_dropping_out_updates_the_condition_without_re_raising()
public async Task A_new_limit_entering_already_acked_is_active_acknowledged_not_retrigger_then_ack()
{
var r = await StartAsync(t => { Set(t, "HInAlarm", 1); Set(t, "HHInAlarm", 1); });
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("HHInAlarm", true); // AckRequired=false: HHAcked is already true
await WaitFor(() => Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.ActiveAcknowledged) >= 2);
await Settled(r);
await r.StopAsync();
var evs = Snapshot(r);
evs.Any(x => x.Kind is AlarmTransitionKind.Retrigger or AlarmTransitionKind.Acknowledge).ShouldBeFalse();
}
/// <summary>M1 — a limit dropping out while another stays active moves the text/severity as a no-row state update.</summary>
[Fact]
public async Task A_limit_dropping_out_is_a_restatement_state_update()
{
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); });
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HHInAlarm", 0);
r.Set("HHInAlarm", false);
var e = await Next(r, x => x.Kind == AlarmTransitionKind.Unspecified);
await Settled(r);
await r.StopAsync();
e.IsRestatement.ShouldBeTrue("the host must write no alerts row for it");
e.Message.ShouldBe($"ALMA {Tag} H alarm");
e.OpcUaSeverity.ShouldBe(600);
lock (r.Events)
{
r.Events.Count(x => x.Kind is AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Clear)
.ShouldBe(1);
}
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Unspecified).ShouldBe(1);
}
/// <summary>The PLC acknowledging every active limit surfaces as one Acknowledge.</summary>
/// <summary>M1 — a non-governing limit flickering (ROC under HH) changes neither text nor severity: no event at all.</summary>
[Fact]
public async Task The_plc_acknowledging_the_active_limits_fires_acknowledge()
public async Task A_non_governing_limit_flicker_emits_nothing()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
var r = await StartAsync(x => { x.Set("HHInAlarm", true); x.Set("ROCPosInAlarm", true); });
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
Set(r.T, "HAcked", 1);
r.Set("ROCPosInAlarm", false);
await Settled(r);
await r.StopAsync();
Snapshot(r).Count.ShouldBe(1);
}
/// <summary>The PLC acknowledging every declared limit surfaces as one Acknowledge.</summary>
[Fact]
public async Task The_plc_acknowledging_the_limits_fires_acknowledge()
{
var r = await StartAsync(x => x.Set("HInAlarm", true));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
r.Set("HAcked", true); r.Set("HHAcked", true); r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true);
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
await Settled(r);
await r.StopAsync();
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1);
}
/// <summary>
/// M2 — H acked; HH enters unacked then returns to normal still unacked (HHAcked stays false). The surviving,
/// acked H must NOT turn into a device "Acknowledged" for an HH excursion nobody acknowledged.
/// </summary>
[Fact]
public async Task An_unacked_excursion_that_returns_to_normal_is_not_reported_as_acknowledged()
{
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HAcked", true); });
await Next(r, x => x.Kind == AlarmTransitionKind.Raise); // other limits' Acked bits are false ⇒ still unacked
r.Set("LAcked", true); r.Set("LLAcked", true); r.Set("ROCPosAcked", true); r.Set("HHAcked", true);
await Next(r, x => x.Kind == AlarmTransitionKind.Acknowledge);
r.Set("HHAcked", false); // HH occurrence (unacked)…
r.Set("HHInAlarm", true);
await Next(r, x => x.Kind == AlarmTransitionKind.Retrigger);
r.Set("HHInAlarm", false); // …returns to normal, still unacked
await Settled(r);
await r.StopAsync();
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Acknowledge).ShouldBe(1,
"only the genuine PLC ack — the unacked HH excursion must keep the condition unacknowledged");
}
/// <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()
{
var r = await StartAsync(t => { Set(t, "LInAlarm", 1); Set(t, "LAcked", 1); });
var r = await StartAsync(x =>
{
x.Set("LInAlarm", true);
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked", "ROCPosAcked" }) x.Set(a, true);
});
var e = await Next(r, x => x.Kind != AlarmTransitionKind.Unspecified);
await Settled(r);
await r.StopAsync();
e.Kind.ShouldBe(AlarmTransitionKind.ActiveAcknowledged);
lock (r.Events) r.Events.Count.ShouldBe(1);
Snapshot(r).Count.ShouldBe(1);
}
/// <summary>A re-subscribe must not re-raise a still-active ALMA (it would un-acknowledge it).</summary>
[Fact]
public async Task A_resubscribe_does_not_re_raise_a_still_active_alma()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
var r = await StartAsync(x => x.Set("HInAlarm", true));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
var sub2 = await r.Drv.SubscribeAlarmsAsync([Tag], CancellationToken.None);
await Settled(r);
await r.Drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await r.StopAsync();
lock (r.Events) r.Events.Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
Snapshot(r).Count(x => x.Kind == AlarmTransitionKind.Raise).ShouldBe(1);
}
/// <summary>A Bad limit read judges nothing that tick: no event, and no false Clear for an active alarm.</summary>
[Fact]
public async Task A_bad_read_does_not_clear_an_active_alarm()
{
var r = await StartAsync(t => Set(t, "HInAlarm", 1));
var r = await StartAsync(x => x.Set("HInAlarm", true));
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
// A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so
// every re-created runtime must fail too (a fresh fake would read all-zero = "all clear").
// every re-created runtime must fail too (a fresh fake would read as "all clear").
var recreations = 0;
r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; };
r.T.Status = (int)AbStatus.NotFound;
// Processed-count wait: the poll loop re-creates the failing runtime on tick after tick — six Bad ticks.
foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound;
await WaitFor(() => Volatile.Read(ref recreations) >= 6);
await r.StopAsync();
lock (r.Events) r.Events.Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse();
}
/// <summary>L2 — a Bad <c>*Acked</c> read is "unknown", not "un-acked": it must neither un-ack nor re-ack.</summary>
[Fact]
public async Task A_bad_acked_read_is_carried_not_stored_as_unacked()
{
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);
// Transient Bad *Acked reads: the evicted runtime is re-created failing, tick after tick.
var bad = 0;
r.Factory.Customise = p =>
{
if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!;
Interlocked.Increment(ref bad);
return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
};
r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound;
await WaitFor(() => Volatile.Read(ref bad) >= 4);
// The link recovers and HAcked reads true again.
r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true };
await WaitFor(() => r.Factory.Tags[$"{Tag}.HAcked"].Status == 0 && r.Factory.Tags[$"{Tag}.HAcked"].Value is true);
await Settled(r);
await r.StopAsync();
var evs = Snapshot(r);
evs.Any(x => x.Kind is AlarmTransitionKind.Acknowledge or AlarmTransitionKind.Retrigger or AlarmTransitionKind.Raise).ShouldBeFalse(
"a transient Bad *Acked read must not produce a second ack/raise");
}
/// <summary>L2 — a Bad severity read must not change the told message/severity.</summary>
[Fact]
public async Task A_bad_severity_read_changes_nothing()
{
var r = await StartAsync(x => { x.Set("HInAlarm", true); x.Set("HHInAlarm", true); });
await Next(r, x => x.Kind == AlarmTransitionKind.Raise);
r.Factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound };
r.Factory.Tags[$"{Tag}.HHSeverity"].Status = (int)AbStatus.NotFound;
await Task.Delay(150);
await Settled(r);
await r.StopAsync();
Snapshot(r).Count.ShouldBe(1, "no Retrigger/update from a Bad severity read");
}
/// <summary>A client acknowledge writes the ALMA's OperAckAll input — never the ALMD-only <c>Acked</c> member.</summary>
@@ -254,4 +354,34 @@ public sealed class AbCipAlmaProjectionTests
r.Factory.Tags[$"{Tag}.OperAckAll"].WriteCount.ShouldBe(1);
r.Factory.Tags.ShouldNotContainKey($"{Tag}.Acked");
}
/// <summary>L4 — one subscription mixing an ALMD and an ALMA: the cursor arithmetic keeps each node's reads apart.</summary>
[Fact]
public async Task A_mixed_almd_and_alma_subscription_projects_each_correctly()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(AlmdTag("Motor"), AlmaTag(), AlmdTag("Pump")), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["Motor", Tag, "Pump"], CancellationToken.None);
await WaitFor(() => factory.Tags.ContainsKey($"{Tag}.HHSeverity") && factory.Tags.ContainsKey("Pump.Severity")
&& factory.Tags.ContainsKey("Motor.Severity") && factory.Tags.ContainsKey($"{Tag}.HAcked"));
factory.Tags["Motor.Severity"].Value = 300;
factory.Tags["Pump.Severity"].Value = 700;
factory.Tags[$"{Tag}.HSeverity"].Value = 600;
factory.Tags["Pump.InAlarm"].Value = true;
factory.Tags[$"{Tag}.HInAlarm"].Value = true;
await WaitFor(() => { lock (events) return events.Count >= 2; });
await Task.Delay(100);
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
List<AlarmEventArgs> evs; lock (events) evs = [.. events];
evs.Count.ShouldBe(2);
var pump = evs.Single(e => e.ConditionId == "Pump");
pump.AlarmType.ShouldBe("ALMD"); pump.Severity.ShouldBe(AlarmSeverity.High);
var alma = evs.Single(e => e.ConditionId == Tag);
alma.AlarmType.ShouldBe("ALMA"); alma.OpcUaSeverity.ShouldBe(600);
}
}
@@ -0,0 +1,228 @@
using System.Text.Json;
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #611 item 1 (hw4 review L3) — an AbCip ALMA reaches its Part 9 condition and its <c>/alerts</c> rows END
/// TO END: the REAL <see cref="AbCipDriver"/> (polling a fake controller, ab_server/Z35162 have no ALMA) raises into
/// the REAL <see cref="DriverHostActor"/>, and the test reads what <c>ForwardNativeAlarm</c> +
/// <c>NativeAlarmProjector</c> turned the projection's events into. The row level is where a drop-out's false
/// "Activated" and a surviving limit's false "Acknowledged" show — the driver's own events look fine.
/// </summary>
public sealed class AbCipAlmaNativeAlarmEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("abcip-alma-e2e");
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('c', 64));
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(600);
private const string DriverId = "drv-abcip";
private const string Device = "ab://10.0.0.5/1,0";
private const string TagPath = "HighPress";
private const string RawPath = "Plant/AbCip/plc/hp";
private static readonly string[] Bits =
["HHInAlarm", "HInAlarm", "LInAlarm", "LLInAlarm", "HHAcked", "HAcked", "LAcked", "LLAcked"];
private static readonly string[] Sevs = ["HHSeverity", "HSeverity", "LSeverity", "LLSeverity"];
/// <summary>
/// raise (H) → escalate (HH enters: Retrigger) → HH drops out (no row, node text moves to H) → the PLC acks
/// everything (ONE "Acknowledged") → HH excursion unacked then gone (still unacked, no false ack) → clear.
/// </summary>
[Fact]
public async Task An_ALMA_walks_raise_escalate_dropout_ack_clear_with_the_right_rows()
{
var db = NewInMemoryDbFactory();
var alerts = SubscribeAlerts();
var (host, publish) = SpawnHostAndApply(db, Seed(db, BuildArtifact(), Rev));
var plc = new FakePlc();
foreach (var b in Bits) plc.Set(b, false);
plc.Set("HHSeverity", 900); plc.Set("HSeverity", 600); plc.Set("LSeverity", 300); plc.Set("LLSeverity", 800);
var drv = new AbCipDriver(
new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = [new RawTagEntry(RawPath, TagConfig(), false, Device)],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
},
DriverId, plc);
try
{
await drv.InitializeAsync("{}", CancellationToken.None);
drv.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
await drv.SubscribeAlarmsAsync([RawPath], CancellationToken.None);
#pragma warning restore OTOPCUA0001
// Raise: H.
plc.Set("HInAlarm", true);
var raised = NextUpdate(publish);
raised.AlarmNodeId.ShouldBe(RawPath);
raised.State.Active.ShouldBeTrue();
raised.State.Acknowledged.ShouldBeFalse();
raised.State.Message.ShouldContain("H alarm");
Row(alerts).TransitionKind.ShouldBe("Activated");
// Escalate: HH enters while H stands — a new unacknowledged occurrence.
plc.Set("HHInAlarm", true);
var esc = NextUpdate(publish);
esc.State.Active.ShouldBeTrue();
esc.State.Acknowledged.ShouldBeFalse();
esc.State.Message.ShouldContain("HH alarm");
Row(alerts).TransitionKind.ShouldBe("Activated");
// HH drops out, H stands: text/severity move to H, but nothing new happened — NO row.
plc.Set("HHInAlarm", false);
var drop = NextUpdate(publish);
drop.State.Active.ShouldBeTrue();
drop.State.Acknowledged.ShouldBeFalse();
drop.State.Message.ShouldContain("H alarm");
drop.State.Message.ShouldNotContain("HH");
drop.State.Severity.ShouldBe((ushort)600);
alerts.ExpectNoMsg(Quiet);
// The PLC acknowledges every declared limit — ONE "Acknowledged" row.
foreach (var a in new[] { "HHAcked", "HAcked", "LAcked", "LLAcked" }) plc.Set(a, true);
NextUpdate(publish).State.Acknowledged.ShouldBeTrue();
Row(alerts).TransitionKind.ShouldBe("Acknowledged");
// An HH excursion nobody acknowledges (HHAcked drops, HH enters then leaves): the surviving, acked H
// must not turn into a device "Acknowledged" for it.
plc.Set("HHAcked", false);
plc.Set("HHInAlarm", true);
NextUpdate(publish).State.Acknowledged.ShouldBeFalse();
Row(alerts).TransitionKind.ShouldBe("Activated");
plc.Set("HHInAlarm", false);
var back = NextUpdate(publish);
back.State.Acknowledged.ShouldBeFalse("the unacknowledged HH excursion keeps the condition unacknowledged");
alerts.ExpectNoMsg(Quiet);
// Positive control: the clear MUST write its row (the absence checks above would pass on a dead host).
plc.Set("HInAlarm", false);
var cleared = NextUpdate(publish);
cleared.State.Active.ShouldBeFalse();
Row(alerts).TransitionKind.ShouldBe("Cleared");
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
drv.Dispose();
}
}
private AlarmTransitionEvent Row(TestProbe alerts) => alerts.ExpectMsg<AlarmTransitionEvent>(Wait);
private static OpcUaPublishActor.AlarmStateUpdate NextUpdate(TestProbe publish) =>
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, Wait);
private TestProbe SubscribeAlerts()
{
var alerts = CreateTestProbe();
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
alerts.ExpectMsg<SubscribeAck>(Wait);
return alerts;
}
private static string TagConfig()
{
var members = Bits.Select(n => (object)new { name = n, dataType = "Bool" })
.Concat(Sevs.Select(n => (object)new { name = n, dataType = "DInt" })).ToArray();
return JsonSerializer.Serialize(new
{
tagPath = TagPath,
dataType = "Structure",
members,
alarm = new { alarmType = "AlarmCondition", severity = 800 },
});
}
private static byte[] BuildArtifact() =>
JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "AbCip", DriverType = DriverTypeNames.AbCip, DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = DriverId, Name = "plc", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new { TagId = "tag-alarm", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "hp",
DataType = "Boolean", AccessLevel = 0, TagConfig = TagConfig() },
},
});
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
{
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed,
CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = blob,
});
ctx.SaveChanges();
return id;
}
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = this.CreateCleanOutcomePublishProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: CreateTestProbe().Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: CreateTestProbe().Ref));
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Wait).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Wait);
return (actor, publish);
}
/// <summary>A fake controller: every member runtime answers from one shared value table, keyed by member name.</summary>
private sealed class FakePlc : IAbCipTagFactory
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, object?> _values = new();
public void Set(string member, object value) => _values[member] = value;
public IAbCipTagRuntime Create(AbCipTagCreateParams p) => new Runtime(this, p.TagName[(p.TagName.LastIndexOf('.') + 1)..]);
private sealed class Runtime(FakePlc plc, string member) : IAbCipTagRuntime
{
public Task InitializeAsync(CancellationToken ct) => Task.CompletedTask;
public Task ReadAsync(CancellationToken ct) => Task.CompletedTask;
public Task WriteAsync(CancellationToken ct) => Task.CompletedTask;
public int GetStatus() => 0;
public object? DecodeValue(AbCipDataType type, int? bitIndex) => plc._values.GetValueOrDefault(member);
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => null;
public object? DecodeArray(AbCipDataType type, int count) => null;
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { }
public void Dispose() { }
}
}
}
@@ -456,6 +456,28 @@ public class NativeAlarmProjectorTests
rows.ShouldBe([kind]);
}
/// <summary>hw4 (AbCip ALMA) — an Unspecified + IsRestatement update on an ACTIVE condition moves the text/severity,
/// keeps active/acked, and writes NO row; an unflagged Unspecified keeps its historical "Activated" row.</summary>
[Fact]
public void A_flagged_Unspecified_update_moves_text_and_severity_without_a_row()
{
var sut = new NativeAlarmProjector();
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.Critical, "HH alarm"), out IReadOnlyList<AlarmTransitionKind> _);
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge), out IReadOnlyList<AlarmTransitionKind> _);
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm", restated: true), out IReadOnlyList<AlarmTransitionKind> rows);
rows.ShouldBeEmpty();
snap.Active.ShouldBeTrue();
snap.Acknowledged.ShouldBeTrue();
snap.Message.ShouldBe("H alarm");
snap.Severity.ShouldBe((ushort)700);
// Control: the same update WITHOUT the flag still writes its row (unchanged behaviour for other sources).
sut.Project("n1", Evt(AlarmTransitionKind.Unspecified, AlarmSeverity.High, "H alarm"), out IReadOnlyList<AlarmTransitionKind> legacy);
legacy.ShouldBe([AlarmTransitionKind.Unspecified]);
}
private static AlarmEventArgs Evt(
AlarmTransitionKind kind,
AlarmSeverity sev = AlarmSeverity.High,
@@ -34,6 +34,7 @@
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<!-- Gitea #656: the real Galaxy (over a scripted gateway alarm stream) and OpcUaClient drivers, so the
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>