f594b29a7a
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
671 lines
32 KiB
C#
671 lines
32 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, 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");
|
||
}
|
||
}
|