Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
T

671 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, bool transitionsOnly = false)
{
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) =>
{
if (transitionsOnly && e.IsQualityOnly) return;
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);
// 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 -------------------------------------------------------
/// <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);
// 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);
}
/// <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);
}
// ---- 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"));
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<FocasDriver>
{
private int _warnings;
public int Warnings() => Volatile.Read(ref _warnings);
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
&& formatter(state, exception).Contains("cnc_rdalmmsg2", StringComparison.Ordinal))
Interlocked.Increment(ref _warnings);
}
}
// ---- Gitea #647 re-review R1–R3 -------------------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[Fact]
public async Task The_condition_message_is_deterministic_for_the_same_alarm_set_across_driver_instances()
{
async Task<string> 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);
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<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");
}
}