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