Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
T
Joseph Doherty 482bafdfac feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:46:48 -04:00

440 lines
20 KiB
C#

using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #647 — FOCAS CNC alarms as Part 9 conditions at <c>ALARM:</c> raw tags. Every event must carry
/// <c>ConditionId == RawPath</c> and a <see cref="AlarmTransitionKind"/>, or the host drops / ignores it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAlarmProjectionTests
{
private const string Host = "focas://10.0.0.5:8193";
private const string Host2 = "focas://10.0.0.6:8193";
private const string ServoPath = "Plant/Cnc/m1/servo";
private const string AnyPath = "Plant/Cnc/m1/any";
private const string OtherPath = "Plant/Cnc/m1/other";
private static FocasActiveAlarm Servo401(short axis = 1) =>
new(401, FocasAlarmType.Servo, axis, "SERVO V-READY OFF");
private static FocasActiveAlarm Overtravel506 => new(506, FocasAlarmType.Overtravel, 2, "OVER TRAVEL +2");
private static FocasActiveAlarm Overheat700 => new(700, FocasAlarmType.Overheat, 0, "OVERHEAT: CONTROL UNIT");
private static string AlarmTagConfig(string address, int? severity = null) =>
JsonSerializer.Serialize(new Dictionary<string, object>
{
["address"] = address,
["dataType"] = "Int32",
["alarm"] = severity is { } s
? new Dictionary<string, object> { ["alarmType"] = "AlarmCondition", ["severity"] = s }
: new Dictionary<string, object> { ["alarmType"] = "AlarmCondition" },
});
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
params RawTagEntry[] tags) => NewDriver([new FocasDeviceOptions(Host, DeviceName: "m1")], tags);
private static (FocasDriver Driver, FakeFocasClientFactory Factory) NewDriver(
IReadOnlyList<FocasDeviceOptions> devices, params RawTagEntry[] tags)
{
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [.. devices],
RawTags = [.. tags],
Probe = new FocasProbeOptions { Enabled = false },
// Enabled is left at its default (false) on purpose: authoring an ALARM: tag is the opt-in (#647).
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, "drv-1", factory);
return (drv, factory);
}
private static RawTagEntry Tag(string rawPath, string address, int? severity = null, string device = "m1") =>
new(rawPath, AlarmTagConfig(address, severity), WriteIdempotent: false, DeviceName: device);
private static FakeFocasClient ClientOf(FakeFocasClientFactory factory) => factory.Clients.Single();
/// <summary>Drives one tick for the driver's live subscription, the way the poll loop would.</summary>
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
private static List<AlarmEventArgs> Capture(FocasDriver drv)
{
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
return events;
}
/// <summary>
/// A driver whose loop is not polling, so ticks are driven by the test only. The subscription is real; the
/// loop is paused right after so its first read cannot race the hand-driven ticks.
/// </summary>
private static async Task<FocasDriver> InitAndSubscribeForTicksAsync(
FocasDriver drv, FakeFocasClientFactory factory, params string[] refs)
{
// The real loop still starts on every subscribe; a CNC whose alarm read always fails makes its ticks
// no-ops (a failed read keeps state), so only the hand-driven ticks below move anything.
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync(refs, CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
return drv;
}
// ---- the transition contract ------------------------------------------------------------------------------
[Fact]
public async Task Raise_then_unchanged_then_clear_carries_RawPath_and_Kind()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401());
Tick(drv, Servo401());
Tick(drv);
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
events.ShouldAllBe(e => e.ConditionId == ServoPath && e.SourceNodeId == Host);
events[0].AlarmType.ShouldBe("Servo");
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axis 1)");
}
[Fact]
public async Task Overlapping_selectors_all_fire_and_OTHER_only_takes_what_no_type_selector_claims()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"), Tag(OtherPath, "ALARM:OTHER"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath, OtherPath);
var events = Capture(drv);
Tick(drv, Servo401());
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath },
customMessage: "the servo alarm drives both ALARM:Servo and ALARM:*; ALARM:* does not claim it away from OTHER, but ALARM:Servo does");
events.Clear();
Tick(drv, Servo401(), Overheat700);
events.ShouldContain(e => e.ConditionId == OtherPath && e.Kind == AlarmTransitionKind.Raise,
"an overheat alarm no type selector claims lands in OTHER");
events.ShouldContain(e => e.ConditionId == AnyPath && e.Kind == AlarmTransitionKind.Retrigger);
events.ShouldNotContain(e => e.ConditionId == ServoPath, "ALARM:Servo's matched set did not change");
}
[Fact]
public async Task A_new_key_retriggers_while_a_non_displayed_member_leaving_emits_nothing()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401()); // Raise — Servo is the winner (Critical)
Tick(drv, Servo401(), Overheat700); // Retrigger — a key the operator has not seen
Tick(drv, Servo401()); // the non-displayed Overheat left: nothing
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger]);
events[1].Message.ShouldEndWith("[+1 more]");
events.Clear();
Tick(drv, Servo401(), Overheat700); // Overheat back: new to the set again ⇒ Retrigger
Tick(drv, Overheat700); // the DISPLAYED alarm left: retrigger with the next one down
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Retrigger]);
events[1].AlarmType.ShouldBe("Overheat");
}
[Fact]
public async Task A_reordered_list_never_re_raises_and_the_displayed_alarm_is_the_most_severe()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, Overheat700, Servo401(), Overtravel506);
Tick(drv, Overtravel506, Servo401(), Overheat700);
Tick(drv, Servo401(), Overheat700, Overtravel506);
events.Count.ShouldBe(1);
// Overtravel and Servo are both Critical; the tie goes to the lower type code (Overtravel = 2).
events[0].AlarmType.ShouldBe("Overtravel");
events[0].Message.ShouldStartWith("506 OVER TRAVEL +2 (type 2 Overtravel, axis 2)");
events[0].Message.ShouldEndWith("[+2 more]");
}
[Fact]
public async Task One_alarm_number_on_two_axes_is_one_alarm_listing_both_axes()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo/401-453"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401(axis: 2), Servo401(axis: 1));
Tick(drv, Servo401(axis: 1)); // the same key on fewer axes is the same alarm: nothing
events.Count.ShouldBe(1);
events[0].Message.ShouldBe("401 SERVO V-READY OFF (type 4 Servo, axes 1, 2)");
}
[Fact]
public async Task Severity_is_the_authored_value_when_present_else_the_type_bucket()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo", severity: 350), Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401());
var authored = events.Single(e => e.ConditionId == ServoPath);
authored.OpcUaSeverity.ShouldBe(350);
authored.Severity.ShouldBe(AlarmSeverity.Low);
var derived = events.Single(e => e.ConditionId == AnyPath);
derived.OpcUaSeverity.ShouldBeNull();
derived.Severity.ShouldBe(AlarmSeverity.Critical);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
FocasAlarmConditionMapper.TypeSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
FocasAlarmConditionMapper.TypeSeverity(42).ShouldBe(AlarmSeverity.High, "an unknown code is High, not ignored");
}
[Fact]
public async Task An_unnamed_type_code_is_shown_raw_so_it_can_be_selected()
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"), Tag(ServoPath, "ALARM:T9"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath, ServoPath);
var events = Capture(drv);
Tick(drv, new FocasActiveAlarm(750, 9, 0, "SPINDLE ALARM"));
events.Count.ShouldBe(2);
events.ShouldAllBe(e => e.AlarmType == "Type9" && e.Message == "750 SPINDLE ALARM (type 9)");
}
[Fact]
public async Task A_superseded_subscription_emits_nothing()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var stale = drv.AlarmProjection.Current!;
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
var events = Capture(drv);
drv.AlarmProjection.Tick(stale, Host, [Servo401()], drv.AlarmTags);
events.ShouldBeEmpty("a retired loop finishing its last read must not deliver");
}
// ---- the told record --------------------------------------------------------------------------------------
[Fact]
public async Task Nothing_is_recorded_while_no_handler_is_attached()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
Tick(drv, Servo401()); // nobody listening (the host detached while reconnecting)
var events = Capture(drv);
Tick(drv, Servo401());
events.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise],
"the raise that happened while detached is delivered once a handler is back");
}
[Fact]
public async Task A_same_set_resubscribe_re_raises_nothing_and_a_changed_set_prunes_only_the_leavers()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"), Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath, AnyPath);
var events = Capture(drv);
Tick(drv, Servo401());
events.Count.ShouldBe(2);
// A reconnect: the actor re-subscribes the SAME set.
events.Clear();
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
Tick(drv, Servo401());
events.ShouldBeEmpty("re-raising an unchanged alarm would un-acknowledge it");
// A deploy drops AnyPath, then a later one brings it back: only AnyPath is told again.
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await drv.SubscribeAlarmsAsync([ServoPath, AnyPath], CancellationToken.None);
await drv.AlarmProjection.PauseAsync();
Tick(drv, Servo401());
events.Select(e => e.ConditionId).ShouldBe([AnyPath]);
events[0].Kind.ShouldBe(AlarmTransitionKind.Raise);
}
// ---- the poll loop, end to end over the fake client -------------------------------------------------------
/// <summary>
/// Gitea #647 defect 4 — a failed read keeps state. Pincer: a throwing read must NOT clear, and (the
/// falsification control) a genuinely empty read MUST — so the first half cannot pass vacuously.
/// </summary>
[Fact]
public async Task A_failed_read_keeps_the_condition_active_while_an_empty_read_clears_it()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0);
var client = ClientOf(factory);
client.SetAlarms(Servo401());
await WaitFor(() => Count(events) == 1);
client.ThrowOnReadAlarms = true;
var readsAtFailure = client.AlarmReadCount;
await WaitFor(() => client.AlarmReadCount >= readsAtFailure + 5);
Count(events).ShouldBe(1, "a failed read is not an empty list");
client.ThrowOnReadAlarms = false;
client.SetAlarms();
await WaitFor(() => Count(events) == 2);
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_reinitialise_neither_re_raises_nor_loses_the_subscription()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0);
factory.Clients[0].SetAlarms(Servo401());
await WaitFor(() => Count(events) == 1);
// A re-initialise without teardown (#630) builds a fresh client; the CNC still reports the alarm.
factory.Customise = () =>
{
var c = new FakeFocasClient();
c.SetAlarms(Servo401());
return c;
};
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 1 && factory.Clients[^1].AlarmReadCount >= 3);
Count(events).ShouldBe(1, "the server was already told; a re-raise would un-acknowledge it");
factory.Clients[^1].SetAlarms();
await WaitFor(() => Count(events) == 2);
lock (events) events[1].Kind.ShouldBe(AlarmTransitionKind.Clear, "the poll resumed after the re-initialise");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Only_devices_that_own_a_subscribed_alarm_tag_are_polled()
{
var (drv, factory) = NewDriver(
[new FocasDeviceOptions(Host, DeviceName: "m1"), new FocasDeviceOptions(Host2, DeviceName: "m2")],
Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
factory.Clients.Count.ShouldBe(1, "the second device owns no alarm tag, so it is never connected for alarms");
drv.GetDeviceState(Host2)!.Client.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Subscribing_with_the_projection_flag_off_does_not_throw()
{
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
handle.ShouldNotBeNull();
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- authoring at init ------------------------------------------------------------------------------------
[Fact]
public async Task Alarm_tags_are_conditions_not_values_and_half_authored_ones_are_skipped_and_reported()
{
var noObject = new RawTagEntry("Plant/Cnc/m1/noobj",
JsonSerializer.Serialize(new { address = "ALARM:Servo", dataType = "Int32" }), false, "m1");
var pmcWithObject = new RawTagEntry("Plant/Cnc/m1/pmc",
JsonSerializer.Serialize(new { address = "R100", dataType = "Byte", alarm = new { severity = 700 } }), false, "m1");
var (drv, _) = NewDriver(Tag(ServoPath, "ALARM:Servo/401", severity: 800), noObject, pmcWithObject);
await drv.InitializeAsync("{}", CancellationToken.None);
drv.AlarmTags.Keys.ShouldBe([ServoPath]);
drv.AlarmTags[ServoPath].AuthoredSeverity.ShouldBe(800);
drv.AlarmTags[ServoPath].Selector.Canonical.ShouldBe("Servo/401");
var skipped = drv.GetSkippedTags()!;
skipped.Sample.Select(s => s.RawPath).Order().ToArray().ShouldBe(new[] { "Plant/Cnc/m1/noobj", "Plant/Cnc/m1/pmc" });
var read = await drv.ReadAsync([ServoPath], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
var write = await drv.WriteAsync([new WriteRequest(ServoPath, 1)], CancellationToken.None);
write[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A re-initialise without teardown (#630) adopting a config that turns a value tag into an alarm tag, or
/// drops it, must not leave the old value-table entry serving reads.
/// </summary>
[Fact]
public async Task A_reinitialise_rebuilds_the_value_and_alarm_tables_from_scratch()
{
const string path = "Plant/Cnc/m1/t";
FocasDriverOptions Options(params RawTagEntry[] tags) => new()
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [.. tags],
Probe = new FocasProbeOptions { Enabled = false },
};
var asValue = Options(new RawTagEntry(path, """{"address":"R100","dataType":"Byte"}""", false, "m1"));
var asAlarm = Options(Tag(path, "ALARM:*"));
var removed = Options();
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(asValue, "drv-1", factory,
rebind: json => new FocasDriverBinding(json switch
{
"""{"v":"alarm"}""" => asAlarm,
"""{"v":"removed"}""" => removed,
_ => asValue,
}, factory));
await drv.InitializeAsync("""{"v":"value"}""", CancellationToken.None);
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
await drv.InitializeAsync("""{"v":"alarm"}""", CancellationToken.None);
drv.AlarmTags.Keys.ShouldBe([path]);
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
await drv.InitializeAsync("""{"v":"removed"}""", CancellationToken.None);
drv.AlarmTags.ShouldBeEmpty();
(await drv.ReadAsync([path], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown,
"a tag the new config removed must not keep reading");
await drv.ShutdownAsync(CancellationToken.None);
}
private static int Count(List<AlarmEventArgs> events)
{
lock (events) return events.Count;
}
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(10);
}
pred().ShouldBeTrue("condition not reached within the timeout");
}
}