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"); } }