using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
///
/// Gitea #647 — FOCAS CNC alarms as Part 9 conditions at ALARM: raw tags. Every event must carry
/// ConditionId == RawPath and a , or the host drops / ignores it.
///
[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
{
["address"] = address,
["dataType"] = "Int32",
["alarm"] = severity is { } s
? new Dictionary { ["alarmType"] = "AlarmCondition", ["severity"] = s }
: new Dictionary { ["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 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();
/// Drives one tick for the driver's live subscription, the way the poll loop would.
private static void Tick(FocasDriver drv, params FocasActiveAlarm[] alarms) =>
drv.AlarmProjection.Tick(drv.AlarmProjection.Current!, Host, alarms, drv.AlarmTags);
private static List Capture(FocasDriver drv, bool transitionsOnly = false)
{
var events = new List();
drv.OnAlarmEvent += (_, e) =>
{
if (transitionsOnly && e.IsQualityOnly) return;
lock (events) events.Add(e);
};
return events;
}
///
/// 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.
///
private static async Task 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);
// Transitions only: each resubscribe starts a real loop whose (deliberately failing) read can annotate a
// quality-only BadNoCommunication event before PauseAsync lands - that raced the exact-list asserts (CI run 911).
var events = Capture(drv, transitionsOnly: true);
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 -------------------------------------------------------
///
/// 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.
///
[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);
// Transitions only: the outage's quality-only BadNoCommunication annotation is pinned separately.
var events = Capture(drv, transitionsOnly: true);
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);
}
///
/// 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.
///
[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);
}
// ---- Gitea #647 review MAJOR-2: an unreachable alarm source must say so ----------------------------------
private const uint BadNoCommunication = 0x80310000u;
private static void TickFailed(FocasDriver drv) =>
drv.AlarmProjection.TickFailed(drv.AlarmProjection.Current!, Host, drv.AlarmTags, new InvalidOperationException("down"));
///
/// A failed read marks each subscribed condition BadNoCommunication ONCE per outage, quality-only (no Clear,
/// no state change); recovery lifts it to Good and, with the alarm unchanged, re-raises nothing.
///
[Fact]
public async Task A_failed_read_annotates_quality_once_and_recovery_lifts_it_without_a_re_raise()
{
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.Clear();
TickFailed(drv);
TickFailed(drv);
events.Count.ShouldBe(2, "one Bad per condition for the whole outage");
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == BadNoCommunication);
events.Select(e => e.ConditionId).Order().ToArray().ShouldBe(new[] { AnyPath, ServoPath });
events.Clear();
Tick(drv, Servo401());
events.Count.ShouldBe(2);
events.ShouldAllBe(e => e.IsQualityOnly && e.QualityStatusCode == 0u, "recovery lifts the Bad and says nothing else");
}
[Fact]
public async Task A_recovery_that_finds_the_alarm_cleared_lifts_quality_then_clears()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
var events = Capture(drv);
Tick(drv, Servo401());
TickFailed(drv);
events.Clear();
Tick(drv);
events.Select(e => (e.Kind, e.QualityStatusCode)).ToArray().ShouldBe(new (AlarmTransitionKind, uint?)[]
{
(AlarmTransitionKind.Unspecified, 0u),
(AlarmTransitionKind.Clear, null),
});
}
[Fact]
public async Task A_persistently_failing_alarm_read_warns_once_per_outage()
{
var logger = new ListLogger();
var factory = new FakeFocasClientFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [Tag(ServoPath, "ALARM:Servo")],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await InitAndSubscribeForTicksAsync(drv, factory, ServoPath);
Capture(drv);
var before = logger.Warnings();
TickFailed(drv);
TickFailed(drv);
TickFailed(drv);
logger.Warnings().ShouldBe(before + 1);
Tick(drv); // recovery ends the episode…
TickFailed(drv); // …so the next outage warns again
logger.Warnings().ShouldBe(before + 2);
}
///
/// An alarm-only driver must be able to trip the comms-loss watchdog (#527): sustained alarm-read failure
/// goes non-Healthy with LastSuccessfulRead FROZEN; a successful alarm read advances it.
///
[Fact]
public async Task Sustained_alarm_read_failure_degrades_health_with_LastSuccessfulRead_frozen()
{
var (drv, factory) = NewDriver(Tag(ServoPath, "ALARM:Servo"));
factory.Customise = () => new FakeFocasClient { ThrowOnReadAlarms = true };
await drv.InitializeAsync("{}", CancellationToken.None);
var atInit = drv.GetHealth().LastSuccessfulRead;
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 3);
var failing = drv.GetHealth();
failing.State.ShouldBe(DriverState.Degraded);
failing.LastSuccessfulRead.ShouldBe(atInit, "a failed read never advances LastSuccessfulRead");
factory.Clients[0].ThrowOnReadAlarms = false;
await WaitFor(() => drv.GetHealth().State == DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value);
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger
{
private int _warnings;
public int Warnings() => Volatile.Read(ref _warnings);
public IDisposable? BeginScope(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
TState state, Exception? exception, Func formatter)
{
if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning
&& formatter(state, exception).Contains("cnc_rdalmmsg2", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
// ---- Gitea #647 re-review R1–R3 -------------------------------------------------------------------------
///
/// R1 — the host treats a respawned driver's Raise as a re-statement only when its message matches what the
/// condition shows, so the message must be a deterministic function of the matched SET: two fresh driver
/// instances reading the same alarms in a different wire (and axis) order say exactly the same thing, and a
/// different set says something different.
///
[Fact]
public async Task The_condition_message_is_deterministic_for_the_same_alarm_set_across_driver_instances()
{
async Task FirstRaiseMessage(params FocasActiveAlarm[] alarms)
{
var (drv, factory) = NewDriver(Tag(AnyPath, "ALARM:*"));
await InitAndSubscribeForTicksAsync(drv, factory, AnyPath);
var events = Capture(drv);
Tick(drv, alarms);
return events.Single().Message;
}
var a = await FirstRaiseMessage(Servo401(axis: 2), Overheat700, Servo401(axis: 1), Overtravel506);
var b = await FirstRaiseMessage(Overtravel506, Servo401(axis: 1), Overheat700, Servo401(axis: 2));
b.ShouldBe(a);
(await FirstRaiseMessage(Overtravel506, Servo401(axis: 1))).ShouldNotBe(a, "a different set must not read as a re-statement");
}
private static Wire.FocasWireException CncRefusal() => new("cnc_rdalmmsg2 refused EW_FUNC", rc: 1);
///
/// R2 — a CNC REFUSAL of cnc_rdalmmsg2 (an rc; the transport is fine) is a capability answer, not comms loss:
/// health stays Healthy (so the #527 watchdog can never reconnect-loop a production control), the conditions
/// are annotated BadNotSupported once, one Warning is logged, and polling continues at the normal cadence.
/// Falsification control: a TRANSPORT failure on the same driver does degrade.
///
[Fact]
public async Task A_CNC_refusal_marks_conditions_not_supported_without_degrading_health()
{
var logger = new ListLogger();
var factory = new FakeFocasClientFactory { Customise = () => new FakeFocasClient { AlarmReadException = CncRefusal() } };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
RawTags = [Tag(ServoPath, "ALARM:Servo")],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = Capture(drv);
await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
await WaitFor(() => factory.Clients.Count > 0 && factory.Clients[0].AlarmReadCount >= 5);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
lock (events)
{
events.Count.ShouldBe(1);
events[0].IsQualityOnly.ShouldBeTrue();
events[0].QualityStatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
}
logger.Warnings().ShouldBe(1);
factory.Clients.Count.ShouldBe(1, "a refusal does not tear the session down");
// Control: the same driver losing its transport DOES degrade, and re-annotates the condition.
factory.Clients[0].AlarmReadException = null;
factory.Clients[0].ThrowOnReadAlarms = true;
await WaitFor(() => drv.GetHealth().State == DriverState.Degraded);
lock (events) events[^1].QualityStatusCode.ShouldBe(BadNoCommunication);
await drv.ShutdownAsync(CancellationToken.None);
}
///
/// R3 — on a mixed driver health is the WORST of the value path and the alarm path, computed together: a
/// failing alarm read keeps the driver Degraded however many value reads succeed (no per-poll flip on
/// /hosts), and a successful alarm read cannot mask a value-path Degraded.
///
[Fact]
public async Task Health_is_the_worst_of_the_value_and_alarm_paths()
{
const string valuePath = "Plant/Cnc/m1/r100";
var (drv, factory) = NewDriver(
new RawTagEntry(valuePath, """{"address":"R100","dataType":"Byte"}""", false, "m1"),
Tag(ServoPath, "ALARM:Servo"));
await drv.InitializeAsync("{}", CancellationToken.None);
// Value reads good, alarm path failing ⇒ Degraded, and it stays Degraded across further good value reads.
(await drv.ReadAsync([valuePath], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
drv.NoteAlarmReadFailed(Host, new InvalidOperationException("socket closed"));
for (var i = 0; i < 3; i++)
{
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a good value read must not mask the failing alarm path");
}
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull("value reads still succeed, so the watchdog is not tripped");
// Alarm path recovers ⇒ Healthy again.
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
await drv.ShutdownAsync(CancellationToken.None);
}
private static int Count(List events)
{
lock (events) return events.Count;
}
private static async Task WaitFor(Func 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");
}
}