Merge b647-focas-alarms: FOCAS native alarms reach Part 9 conditions (ALARM: address) + respawn keeps acks host-side (#647)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+39
-17
@@ -154,9 +154,13 @@ public sealed class WireBackendCoverageTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that alarm raise then clear emits both events via the wire backend.</summary>
|
||||
/// <summary>
|
||||
/// Gitea #647 — CNC alarms injected into the mock drive ONE condition per <c>ALARM:</c> tag, keyed by the tag's
|
||||
/// RawPath, with a <see cref="AlarmTransitionKind"/>: an Overtravel alarm raises both <c>ALARM:Overtravel</c>
|
||||
/// and <c>ALARM:*</c>; a second (servo) alarm retriggers only the summary; clearing the list clears both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
|
||||
public async Task Alarm_selectors_raise_retrigger_and_clear_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -165,16 +169,15 @@ public sealed class WireBackendCoverageTests
|
||||
// Start with no active alarms.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
|
||||
const string overtravelPath = "Plant/Cnc/m1/overtravel";
|
||||
const string anyPath = "Plant/Cnc/m1/any";
|
||||
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = [],
|
||||
RawTags = [AlarmTag(overtravelPath, "ALARM:Overtravel"), AlarmTag(anyPath, "ALARM:*")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(200) },
|
||||
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (drv)
|
||||
@@ -183,27 +186,46 @@ public sealed class WireBackendCoverageTests
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], ct);
|
||||
var sub = await drv.SubscribeAlarmsAsync([overtravelPath, anyPath], ct);
|
||||
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[] { new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" } },
|
||||
}, ct);
|
||||
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Raise) == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Raise one alarm.
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
alarms = new[]
|
||||
{
|
||||
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
new { alm_no = 506, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
|
||||
new { alm_no = 401, type = 4, axis = 2, msg = "TEST SERVO" },
|
||||
},
|
||||
}, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
|
||||
await WaitFor(() => Snapshot(events).Any(e => e.Kind == AlarmTransitionKind.Retrigger), TimeSpan.FromSeconds(5));
|
||||
|
||||
// Clear.
|
||||
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
|
||||
await WaitFor(() => Snapshot(events).Count(e => e.Kind == AlarmTransitionKind.Clear) == 2, TimeSpan.FromSeconds(5));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, ct);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(DeviceHost);
|
||||
var all = Snapshot(events);
|
||||
all.Where(e => e.ConditionId == overtravelPath).Select(e => e.Kind).ToArray()
|
||||
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Clear });
|
||||
all.Where(e => e.ConditionId == anyPath).Select(e => e.Kind).ToArray()
|
||||
.ShouldBe(new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear });
|
||||
all[0].AlarmType.ShouldBe("Overtravel");
|
||||
all[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
all.ShouldAllBe(e => e.SourceNodeId == DeviceHost);
|
||||
}
|
||||
|
||||
RawTagEntry AlarmTag(string rawPath, string address) => new(rawPath,
|
||||
"{\"address\":\"" + address + "\",\"dataType\":\"Int32\",\"alarm\":{\"alarmType\":\"AlarmCondition\"}}",
|
||||
WriteIdempotent: false, DeviceName: DeviceHost);
|
||||
|
||||
static List<AlarmEventArgs> Snapshot(List<AlarmEventArgs> events)
|
||||
{
|
||||
lock (events) return [.. events];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,10 +123,33 @@ internal class FakeFocasClient : IFocasClient
|
||||
/// <summary>Gets the list of active alarms.</summary>
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the alarm read throws (Gitea #647: a failed read).</summary>
|
||||
public bool ThrowOnReadAlarms { get; set; }
|
||||
|
||||
/// <summary>Gets or sets an exception the alarm read throws (e.g. a CNC refusal carrying an rc).</summary>
|
||||
public Exception? AlarmReadException { get; set; }
|
||||
|
||||
/// <summary>Gets the number of alarm reads served (successful or not).</summary>
|
||||
public int AlarmReadCount => Volatile.Read(ref _alarmReadCount);
|
||||
|
||||
private int _alarmReadCount;
|
||||
|
||||
/// <summary>Replaces the active alarm list atomically with respect to the poll thread.</summary>
|
||||
/// <param name="alarms">The new active alarms.</param>
|
||||
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||
{
|
||||
lock (Alarms) { Alarms.Clear(); Alarms.AddRange(alarms); }
|
||||
}
|
||||
|
||||
/// <summary>Reads active alarms asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _alarmReadCount);
|
||||
if (AlarmReadException is { } refusal) throw refusal;
|
||||
if (ThrowOnReadAlarms) throw new InvalidOperationException("cnc_rdalmmsg2 failed (fake)");
|
||||
lock (Alarms) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
}
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
/// <summary>Gets or sets the system information.</summary>
|
||||
|
||||
@@ -1,196 +1,668 @@
|
||||
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 (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
||||
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 = [new FocasDeviceOptions(Host)],
|
||||
RawTags = [],
|
||||
Devices = [.. devices],
|
||||
RawTags = [.. tags],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = alarmsEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
||||
},
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe without enable throws NotSupported.</summary>
|
||||
[Fact]
|
||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
||||
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 (drv, _) = NewDriver(alarmsEnabled: false);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that raise then clear emits both events.</summary>
|
||||
[Fact]
|
||||
public async Task Raise_then_clear_emits_both_events()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
||||
// poke the alarm list so we don't race the poll loop.
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
var client = factory.Clients[0];
|
||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
||||
|
||||
// Clear — the clear event wraps the original message with "(cleared)".
|
||||
client.Alarms.Clear();
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(Host);
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
if (transitionsOnly && e.IsQualityOnly) return;
|
||||
lock (events) events.Add(e);
|
||||
};
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for the reconnect poll-loop leak (#399 sibling): the owning
|
||||
/// DriverInstanceActor re-subscribes alarms on every Connected entry without first
|
||||
/// calling Unsubscribe, and the driver object (and its projection) survives the in-place
|
||||
/// reconnect. Each SubscribeAlarmsAsync used to start a fresh, never-cancelled poll loop —
|
||||
/// so after N reconnects there were N live loops all polling the device and all firing the
|
||||
/// same raise/clear transition, producing DUPLICATE alarm events.
|
||||
///
|
||||
/// This test simulates two re-subscribes (one reconnect) against the same source, then
|
||||
/// drives ONE device raise. After the collapse-to-single-loop fix exactly one loop is alive
|
||||
/// so the raise must fire exactly once. Before the fix both loops fire it ⇒ two 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.
|
||||
/// </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 Resubscribe_collapses_to_single_loop_no_duplicate_raise()
|
||||
public async Task A_failed_read_keeps_the_condition_active_while_an_empty_read_clears_it()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
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 raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
// Count only the raise (the initial 0->1) — clears wrap the message in "(cleared)".
|
||||
if (!e.Message.Contains("cleared")) lock (raises) raises.Add(e);
|
||||
};
|
||||
var handle = await drv.SubscribeAlarmsAsync([ServoPath], CancellationToken.None);
|
||||
|
||||
// First subscribe creates + connects the per-device client and starts polling. Wait for
|
||||
// it + a couple empty-list ticks so the loop's LastByDevice baseline is seeded empty.
|
||||
var sub1 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(120);
|
||||
|
||||
// Second subscribe = the actor re-subscribing across a reconnect. Its loop reuses the same
|
||||
// connected DeviceState.Client (EnsureConnectedAsync), so it polls the same alarm list.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await Task.Delay(120); // let sub2's loop seed its own empty LastByDevice baseline
|
||||
|
||||
// One device raise transition. Add to every created client so that whichever instance the
|
||||
// live loop(s) poll, the raise is observed (EnsureConnectedAsync may have churned clients).
|
||||
foreach (var c in factory.Clients)
|
||||
c.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
|
||||
// Wait until at least one raise is observed, then give any leaked second loop ample time
|
||||
// (well past several 30ms poll intervals) to ALSO fire its duplicate before we assert.
|
||||
await WaitFor(() => { lock (raises) return raises.Count > 0; }, TimeSpan.FromSeconds(3));
|
||||
await Task.Delay(200);
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
lock (raises)
|
||||
// ---- 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()
|
||||
{
|
||||
raises.Count.ShouldBe(1,
|
||||
"exactly one poll loop must survive a re-subscribe; a leaked loop fires a duplicate raise");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
|
||||
// ---- 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 Tick_diffs_raises_and_clears_without_polling_loop()
|
||||
public async Task The_condition_message_is_deterministic_for_the_same_alarm_set_across_driver_instances()
|
||||
{
|
||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
||||
// subscription — simpler than standing up the full loop.
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
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);
|
||||
|
||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
||||
var sub = new FocasAlarmProjection.Subscription(
|
||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
||||
new CancellationTokenSource());
|
||||
// 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");
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
// Alarm path recovers ⇒ Healthy again.
|
||||
drv.NoteAlarmReadSucceeded(Host);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// Tick 1 — raise two alarms.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(2);
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
|
||||
// Tick 2 — same alarms stay active → no new events.
|
||||
events.Clear();
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldEndWith("(cleared)");
|
||||
events[0].AlarmType.ShouldBe("Parameter");
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that severity mapping matches docs.</summary>
|
||||
[Fact]
|
||||
public void Severity_mapping_matches_docs()
|
||||
private static int Count(List<AlarmEventArgs> events)
|
||||
{
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
lock (events) return events.Count;
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
private static async Task WaitFor(Func<bool> pred, int timeoutMs = 3000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(30);
|
||||
await Task.Delay(10);
|
||||
}
|
||||
pred().ShouldBeTrue("condition not reached within the timeout");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #647 — the <c>ALARM:</c> address grammar, shared by the editor, the deploy gate and the driver.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmSelectorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("ALARM:*", "ALARM:*")]
|
||||
[InlineData("alarm:other", "ALARM:OTHER")]
|
||||
[InlineData("ALARM:servo", "ALARM:Servo")]
|
||||
[InlineData("ALARM:Servo/401", "ALARM:Servo/401")]
|
||||
[InlineData("ALARM:SERVO/401-453", "ALARM:Servo/401-453")]
|
||||
[InlineData("ALARM:Servo/401-401", "ALARM:Servo/401")]
|
||||
[InlineData("ALARM:T9", "ALARM:T9")]
|
||||
[InlineData("ALARM:t9/750-758", "ALARM:T9/750-758")]
|
||||
[InlineData(" ALARM:MacroAlarm/3000 ", "ALARM:MacroAlarm/3000")]
|
||||
public void Valid_selectors_parse_to_their_canonical_form(string address, string canonical)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(address);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
parsed.Canonical.ShouldBe(canonical);
|
||||
FocasAddress.TryParse(parsed.Canonical)!.Canonical.ShouldBe(canonical, "canonical must round-trip");
|
||||
FocasAddress.DescribeAlarmAddressError(address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ALARM:")]
|
||||
[InlineData("ALARM:Bogus")]
|
||||
[InlineData("ALARM:Servo/")]
|
||||
[InlineData("ALARM:Servo/x")]
|
||||
[InlineData("ALARM:Servo/-5")]
|
||||
[InlineData("ALARM:Servo/10-5")]
|
||||
[InlineData("ALARM:Servo/1-")]
|
||||
[InlineData("ALARM:T")]
|
||||
[InlineData("ALARM:T-1")]
|
||||
[InlineData("ALARM:T99999")]
|
||||
[InlineData("ALARM:Servo /401")]
|
||||
[InlineData("ALARM:*/401")]
|
||||
public void Malformed_selectors_are_refused_with_a_reason(string address)
|
||||
{
|
||||
FocasAddress.TryParse(address).ShouldBeNull();
|
||||
FocasAddress.DescribeAlarmAddressError(address).ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>"A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC.</summary>
|
||||
[Fact]
|
||||
public void Alarm_prefix_is_not_read_as_a_PMC_A_address()
|
||||
{
|
||||
FocasAddress.TryParse("A10")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
FocasAddress.TryParse("ALARM:*")!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
FocasAddress.DescribeAlarmAddressError("A10").ShouldBeNull("not an ALARM: address at all");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Type_selector_matches_on_type_and_inclusive_number_range()
|
||||
{
|
||||
var range = FocasAlarmSelector.TryParse("Servo/401-453")!;
|
||||
range.Matches(FocasAlarmType.Servo, 401).ShouldBeTrue();
|
||||
range.Matches(FocasAlarmType.Servo, 453).ShouldBeTrue();
|
||||
range.Matches(FocasAlarmType.Servo, 454).ShouldBeFalse();
|
||||
range.Matches(FocasAlarmType.Overtravel, 401).ShouldBeFalse("(type, number) is the identity");
|
||||
|
||||
var any = FocasAlarmSelector.TryParse("*")!;
|
||||
any.Matches(FocasAlarmType.Overheat, 1).ShouldBeTrue();
|
||||
|
||||
// OTHER depends on the device's other selectors, so it never matches on its own terms.
|
||||
FocasAlarmSelector.TryParse("OTHER")!.Matches(FocasAlarmType.Servo, 401).ShouldBeFalse();
|
||||
|
||||
// A raw code selects exactly that code — the series-independent form.
|
||||
FocasAlarmSelector.TryParse("T4")!.Matches(FocasAlarmType.Servo, 1).ShouldBeTrue();
|
||||
FocasAlarmSelector.TryParse("T9")!.Matches(FocasAlarmType.Servo, 1).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_named_type_resolves_through_the_one_table()
|
||||
{
|
||||
foreach (var name in FocasAlarmType.Names)
|
||||
{
|
||||
FocasAlarmType.TryResolve(name.ToUpperInvariant(), out var code).ShouldBeTrue(name);
|
||||
FocasAlarmType.NameOf(code).ShouldBe(name);
|
||||
}
|
||||
FocasAlarmType.NameOf(9).ShouldBeNull("codes outside the 16i-era table are not invented");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capability_matrix_accepts_an_alarm_address_on_every_series()
|
||||
{
|
||||
var address = FocasAddress.TryParse("ALARM:Servo/401")!;
|
||||
foreach (var series in Enum.GetValues<FocasCncSeries>())
|
||||
FocasCapabilityMatrix.Validate(series, address).ShouldBeNull(series.ToString());
|
||||
}
|
||||
|
||||
// ---- the deploy-time inspector (the gate CSV import / API authoring meets) ----
|
||||
|
||||
[Fact]
|
||||
public void Inspect_is_clean_for_an_alarm_selector_with_its_alarm_object()
|
||||
{
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/401-453","dataType":"Int32","alarm":{"severity":800}}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_advises_when_an_alarm_selector_has_no_alarm_object_or_an_alarm_object_has_no_selector()
|
||||
{
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:*","dataType":"Int32"}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("no 'alarm' object");
|
||||
FocasTagDefinitionFactory.Inspect("""{"address":"R100","dataType":"Byte","alarm":{}}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("not an ALARM: selector");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inspect_names_the_selector_fault_for_a_malformed_alarm_address()
|
||||
{
|
||||
var warning = FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/10-5","alarm":{}}""").ShouldHaveSingleItem();
|
||||
warning.ShouldContain("reversed");
|
||||
warning.ShouldContain(FocasAddress.DescribeAlarmAddressError("ALARM:Servo/10-5")!,
|
||||
customMessage: "the gate and the editor must say the same thing about the same string");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_mapper_reads_the_alarm_object_and_its_optional_integer_severity()
|
||||
{
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":650}}""", "p", out var a).ShouldBeTrue();
|
||||
a.Alarm.ShouldBe(new FocasTagAlarm(650));
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":"high"}}""", "p", out var b).ShouldBeTrue();
|
||||
b.Alarm.ShouldBe(new FocasTagAlarm(null));
|
||||
FocasTagDefinitionFactory.FromTagConfig("""{"address":"R100"}""", "p", out var c).ShouldBeTrue();
|
||||
c.Alarm.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review MINOR-5 — a non-empty alarm payload that fits neither record stride is malformed, not
|
||||
/// "no alarms" (which would mass-clear every condition on the device). Empty stays empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_malformed_alarm_payload_throws_while_an_empty_one_is_no_alarms()
|
||||
{
|
||||
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
|
||||
FocasWireClient.ParseAlarms(new byte[76], 32).Count.ShouldBe(1);
|
||||
FocasWireClient.ParseAlarms(new byte[160], 32).Count.ShouldBe(2);
|
||||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
|
||||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[12], 32));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
|
||||
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty()
|
||||
{
|
||||
using var client = new WireFocasClient();
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => client.ReadAlarmsAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -58,9 +58,9 @@ public sealed class GatewayGalaxyAlarmFeedTests
|
||||
|
||||
observed.Count.ShouldBe(3);
|
||||
|
||||
// Active snapshot entry → Raise.
|
||||
// Active (unacked) snapshot entry → Retrigger (#647 review: forces the upstream's unack on the host).
|
||||
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Retrigger);
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
|
||||
|
||||
@@ -232,6 +232,21 @@ public sealed class OpcUaClientAlarmTests
|
||||
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review — with no prior this driver told the host (first sight after start or a deploy respawn),
|
||||
/// an unacknowledged upstream target goes out as Retrigger, never Raise: the host keeps an existing ack across
|
||||
/// a Raise on an already-active condition, so only a Retrigger makes the host match the upstream's unack.
|
||||
/// An acknowledged target keeps its Raise (a re-statement there is exactly right).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(false, false, new[] { AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
public void An_unknown_prior_tells_an_unacked_upstream_with_Retrigger(bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((false, true), (active, acked), priorKnown: false).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
||||
|
||||
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||
@@ -357,7 +372,9 @@ public sealed class OpcUaClientAlarmTests
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
// First sight of an UNACKED upstream condition is a Retrigger (#647 review: the host keeps an ack across a
|
||||
// Raise on an already-active condition, and only the upstream knows it is unacknowledged).
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Retrigger, AlarmTransitionKind.Acknowledge]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
@@ -63,4 +63,37 @@ public sealed class FocasAddressBuilderTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gitea #647: ALARM: selectors ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("*", null, null, "ALARM:*")]
|
||||
[InlineData("*", 401, null, "ALARM:*")] // numbers apply to a type scope only
|
||||
[InlineData("OTHER", null, null, "ALARM:OTHER")]
|
||||
[InlineData("Servo", null, null, "ALARM:Servo")]
|
||||
[InlineData("Servo", 401, null, "ALARM:Servo/401")]
|
||||
[InlineData("Servo", 401, 453, "ALARM:Servo/401-453")]
|
||||
[InlineData("Servo", 401, 401, "ALARM:Servo/401")]
|
||||
[InlineData("T9", 750, 758, "ALARM:T9/750-758")]
|
||||
public void BuildAlarm_produces_a_selector_the_driver_can_parse(string scope, int? lo, int? hi, string expected)
|
||||
{
|
||||
var built = FocasAddressBuilder.BuildAlarm(scope, lo, hi);
|
||||
|
||||
built.ShouldBe(expected);
|
||||
FocasAddress.TryParse(built)!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Servo", 453, 401)] // reversed range
|
||||
[InlineData("Bogus", null, null)] // not a type
|
||||
[InlineData("", null, null)]
|
||||
public void BuildAlarm_returns_empty_rather_than_an_unusable_selector(string scope, int? lo, int? hi)
|
||||
=> FocasAddressBuilder.BuildAlarm(scope, lo, hi).ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Every_offered_alarm_scope_composes_a_parseable_selector()
|
||||
{
|
||||
foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
||||
FocasAddress.TryParse(FocasAddressBuilder.BuildAlarm(scope, 1, 2)).ShouldNotBeNull(scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModelTests
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("FIXED:Identity/SeriesNumber")]
|
||||
[InlineData("ALARM:*")]
|
||||
[InlineData("ALARM:OTHER")]
|
||||
[InlineData("ALARM:Servo/401-453")]
|
||||
[InlineData("ALARM:T9")]
|
||||
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
|
||||
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
|
||||
|
||||
@@ -104,6 +108,25 @@ public sealed class FocasTagConfigModelTests
|
||||
// The operator has to be able to act on it, so the message names the legal forms.
|
||||
error.ShouldContain("PARAM:");
|
||||
error.ShouldContain("FIXED:");
|
||||
error.ShouldContain("ALARM:");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 — a malformed ALARM: selector gets the selector's own reason, the same text the deploy-time
|
||||
/// inspector gives for the same string (one authority: <c>FocasAddress.DescribeAlarmAddressError</c>).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("ALARM:Servo/10-5")]
|
||||
[InlineData("ALARM:Bogus")]
|
||||
[InlineData("ALARM:")]
|
||||
public void Validate_rejects_a_malformed_alarm_selector_with_the_gate_s_reason(string address)
|
||||
{
|
||||
var error = new FocasTagConfigModel { Address = address }.Validate();
|
||||
|
||||
error.ShouldNotBeNull();
|
||||
error.ShouldContain(FocasAddress.DescribeAlarmAddressError(address)!);
|
||||
FocasTagDefinitionFactory.Inspect("{\"address\":\"" + address + "\",\"alarm\":{}}")
|
||||
.ShouldContain(w => w.Contains(FocasAddress.DescribeAlarmAddressError(address)!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+5
-3
@@ -256,7 +256,7 @@ public sealed class OpcUaClientDriverWireTests
|
||||
|
||||
/// <summary>
|
||||
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
|
||||
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
|
||||
/// reported (Retrigger since #647, keyed by the RawPath) right after subscribe, not only on its next transition. A second
|
||||
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
|
||||
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
|
||||
/// </summary>
|
||||
@@ -291,9 +291,11 @@ public sealed class OpcUaClientDriverWireTests
|
||||
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
|
||||
|
||||
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
|
||||
// First sight of an active+UNACKED upstream condition is a Retrigger (#647 review): the host keeps an ack
|
||||
// across a Raise on an already-active condition, so only a Retrigger forces the upstream's unack.
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Retrigger));
|
||||
|
||||
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
|
||||
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Retrigger);
|
||||
raise.ConditionId.ShouldBe(AlarmRef);
|
||||
raise.Message.ShouldBe("already hot");
|
||||
raise.OpcUaSeverity.ShouldBe(800);
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS <c>"{host}#{type}:{number}"</c> before #647) has
|
||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||
[Fact]
|
||||
public void A_decorated_condition_id_is_dropped()
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 — a FOCAS CNC alarm reaches its Part 9 condition END TO END: the REAL <see cref="FocasDriver"/>
|
||||
/// (over a fake CNC client), fed the raw tags exactly as the deployment artifact delivers them, raises into the
|
||||
/// REAL <see cref="DriverHostActor"/>, whose <c>ForwardNativeAlarm</c> + <c>NativeAlarmProjector</c> must turn it
|
||||
/// into an Active and then an inactive condition at the tag's RawPath.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The lesson this exists for: OpcUaClient and AbCip native alarms were "routed" — events raised, unit tests
|
||||
/// green — but never reached a condition, because nothing checked the event against the key the host actually
|
||||
/// routes on (ConditionId == RawPath) or the field its projector actually reads (Kind). A test that stops at the
|
||||
/// driver's event cannot see either.
|
||||
/// </remarks>
|
||||
public sealed class FocasNativeAlarmEndToEndTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("focas-alarm-e2e");
|
||||
private static readonly RevisionHash Rev = RevisionHash.Parse(new string('f', 64));
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
private const string DriverId = "drv-focas";
|
||||
private const string ExpectedRawPath = "Plant/Cnc/m1/servo_alarms";
|
||||
|
||||
[Fact]
|
||||
public async Task A_CNC_servo_alarm_activates_then_clears_the_condition_at_the_RawPath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildArtifact();
|
||||
var deploymentId = Seed(db, blob, Rev);
|
||||
var (host, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// The raw tags exactly as the driver receives them: the artifact's merged DriverConfig RawTags array.
|
||||
var rawTags = RawTagsFor(blob, DriverId);
|
||||
rawTags.Select(t => t.RawPath).ShouldBe([ExpectedRawPath]);
|
||||
|
||||
var cnc = new AlarmOnlyFocasClient();
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||
RawTags = rawTags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||
}, DriverId, new SingleClientFactory(cnc));
|
||||
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
||||
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
// The host's alarm subscription set is the alarm-bearing raw tags' RawPaths. Called directly, as the
|
||||
// DriverInstanceActor would through its invoker: the routing is under test, not resilience wrapping.
|
||||
#pragma warning disable OTOPCUA0001
|
||||
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
try
|
||||
{
|
||||
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
|
||||
var active = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
active.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||
active.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
active.State.Active.ShouldBeTrue("the Raise must project to an ACTIVE condition, not 'keep prior state'");
|
||||
active.State.Acknowledged.ShouldBeFalse();
|
||||
active.State.Severity.ShouldBe((ushort)800, "the authored alarm.severity is the condition's severity");
|
||||
active.State.Message.ShouldStartWith("401 SERVO V-READY OFF");
|
||||
|
||||
cnc.SetAlarms();
|
||||
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
cleared.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||
cleared.State.Active.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review (MAJOR-1) — a deploy that changes ANY of the driver's config respawns it, and the fresh
|
||||
/// instance has no record of what the server was told. Its first poll re-reads the still-active alarm and
|
||||
/// sends Raise; the host must treat that as a re-statement: the operator's acknowledgement survives and no
|
||||
/// second "Activated" alerts row is written. Driven through the real host with a FRESH driver instance.
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// Re-review R1 — the other half: a DIFFERENT alarm that appeared while the driver was down (here a second
|
||||
/// servo alarm, so the condition's displayed set changed) is a new statement even though the fresh driver
|
||||
/// can only say Raise. It must un-acknowledge and write its alerts row.
|
||||
/// </para>
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_respawned_driver_re_reading_an_acknowledged_active_alarm_keeps_the_ack(bool newAlarmDuringRespawn)
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var blob = BuildArtifact();
|
||||
var alerts = CreateTestProbe();
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(new Subscribe(ScriptedAlarmHostActor.AlertsTopic, alerts.Ref), alerts.Ref);
|
||||
alerts.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||
var (host, publish, coordinator) = SpawnHostAndApply(db, Seed(db, blob, Rev));
|
||||
var rawTags = RawTagsFor(blob, DriverId);
|
||||
var cnc = new AlarmOnlyFocasClient();
|
||||
cnc.SetAlarms(new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"));
|
||||
|
||||
var first = await StartDriverAsync(rawTags, cnc, host);
|
||||
try
|
||||
{
|
||||
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5))
|
||||
.State.Acknowledged.ShouldBeFalse();
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await first.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// The operator acknowledges, then a deploy changes the driver's config => stop + respawn.
|
||||
host.Tell(new DriverHostActor.RouteNativeAlarmAck(ExpectedRawPath, "seen", "op"));
|
||||
var revB = RevisionHash.Parse(new string('e', 64));
|
||||
host.Tell(new DispatchDeployment(Seed(db, blob, revB), revB, CorrelationId.NewId()));
|
||||
coordinator.FishForMessage<ApplyAck>(a => a.Outcome == ApplyAckOutcome.Applied, TimeSpan.FromSeconds(5));
|
||||
publish.FishForMessage<OpcUaPublishActor.RebuildAddressSpace>(_ => true, TimeSpan.FromSeconds(5));
|
||||
|
||||
if (newAlarmDuringRespawn)
|
||||
cnc.SetAlarms(
|
||||
new FocasActiveAlarm(401, FocasAlarmType.Servo, 1, "SERVO V-READY OFF"),
|
||||
new FocasActiveAlarm(410, FocasAlarmType.Servo, 2, "SERVO EXCESS ERROR"));
|
||||
|
||||
var respawned = await StartDriverAsync(rawTags, cnc, host);
|
||||
try
|
||||
{
|
||||
var restated = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(_ => true, TimeSpan.FromSeconds(5));
|
||||
restated.AlarmNodeId.ShouldBe(ExpectedRawPath);
|
||||
restated.State.Active.ShouldBeTrue();
|
||||
if (newAlarmDuringRespawn)
|
||||
{
|
||||
restated.State.Acknowledged.ShouldBeFalse("an alarm the operator has not seen must not inherit the old ack");
|
||||
alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
else
|
||||
{
|
||||
restated.State.Acknowledged.ShouldBeTrue("a respawned driver's re-statement must not un-acknowledge");
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await respawned.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<FocasDriver> StartDriverAsync(List<RawTagEntry> rawTags, IFocasClient cnc, IActorRef host)
|
||||
{
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "m1")],
|
||||
RawTags = rawTags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions { PollInterval = TimeSpan.FromMilliseconds(20) },
|
||||
}, DriverId, new SingleClientFactory(cnc));
|
||||
driver.OnAlarmEvent += (_, e) => host.Tell(new DriverInstanceActor.AttributeAlarmPublished(DriverId, e));
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
#pragma warning disable OTOPCUA0001 // the routing is under test, not resilience wrapping
|
||||
await driver.SubscribeAlarmsAsync([.. rawTags.Select(t => t.RawPath)], CancellationToken.None);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
return driver;
|
||||
}
|
||||
|
||||
/// <summary>The seeded artifact's FOCAS driver raw tags, decoded through the production artifact reader.</summary>
|
||||
private static List<RawTagEntry> RawTagsFor(byte[] blob, string driverInstanceId)
|
||||
{
|
||||
using var artifact = DeploymentArtifact.Open(blob);
|
||||
var spec = artifact.ParseDriverInstances().Single(s => s.DriverInstanceId == driverInstanceId);
|
||||
using var doc = JsonDocument.Parse(spec.DriverConfig);
|
||||
return JsonSerializer.Deserialize<List<RawTagEntry>>(
|
||||
doc.RootElement.GetProperty("RawTags").GetRawText(),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
private static byte[] BuildArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
// Enabled=false: the host must not spawn a real wire driver — the test drives its own instance.
|
||||
new { DriverInstanceId = DriverId, RawFolderId = "rf-plant", Name = "Cnc", DriverType = "FOCAS", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = "dev-m1", DriverInstanceId = DriverId, Name = "m1", DeviceConfig = JsonSerializer.Serialize(new { HostAddress = Host }) },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-servo",
|
||||
DeviceId = "dev-m1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = "servo_alarms",
|
||||
DataType = "Int32",
|
||||
AccessLevel = 0,
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
address = "ALARM:Servo",
|
||||
dataType = "Int32",
|
||||
alarm = new { alarmType = "AlarmCondition", severity = 800 },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
private static DeploymentId Seed(IDbContextFactory<OtOpcUaConfigDbContext> db, byte[] blob, RevisionHash rev)
|
||||
{
|
||||
var id = DeploymentId.NewId();
|
||||
using var ctx = db.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = rev.Value,
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = blob,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private (IActorRef Actor, TestProbe Publish, TestProbe Coordinator) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
var publish = this.CreateCleanOutcomePublishProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
dependencyMux: CreateTestProbe().Ref,
|
||||
opcUaPublishActor: publish.Ref,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: CreateTestProbe().Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, Rev, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||
return (actor, publish, coordinator);
|
||||
}
|
||||
|
||||
private sealed class SingleClientFactory(IFocasClient client) : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => client;
|
||||
|
||||
public void EnsureUsable() { }
|
||||
}
|
||||
|
||||
/// <summary>A CNC that only answers the alarm list — everything else this test never reaches.</summary>
|
||||
private sealed class AlarmOnlyFocasClient : IFocasClient
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private FocasActiveAlarm[] _alarms = [];
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public void SetAlarms(params FocasActiveAlarm[] alarms)
|
||||
{
|
||||
lock (_gate) _alarms = alarms;
|
||||
}
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate) return Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. _alarms]);
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken) => Task.FromResult(true);
|
||||
|
||||
public void Dispose() => IsConnected = false;
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) => throw Unused();
|
||||
|
||||
private static NotSupportedException Unused() => new("not reached by the alarm end-to-end test");
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,50 @@ public class NativeAlarmProjectorTests
|
||||
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 review — a Raise on a condition that is ALREADY active is a re-statement (a respawned driver
|
||||
/// re-reading a still-active alarm it never told the server about): it keeps the acknowledgement and is
|
||||
/// flagged so the host writes no duplicate "Activated" row. Only a Retrigger (or inactive → active) un-acks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
|
||||
first.ShouldBeFalse("inactive → active is a real transition");
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
|
||||
restatement.ShouldBeTrue();
|
||||
restated.Active.ShouldBeTrue();
|
||||
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
|
||||
|
||||
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
|
||||
retrigger.ShouldBeFalse();
|
||||
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
|
||||
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
|
||||
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
|
||||
{
|
||||
var sut = new NativeAlarmProjector();
|
||||
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
|
||||
sut.MarkAcknowledged("n1");
|
||||
|
||||
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
|
||||
r1.ShouldBeTrue();
|
||||
same.Acknowledged.ShouldBeTrue();
|
||||
|
||||
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
|
||||
r2.ShouldBeFalse("a different alarm is not a re-statement");
|
||||
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
|
||||
}
|
||||
|
||||
private static AlarmEventArgs Evt(
|
||||
AlarmTransitionKind kind,
|
||||
AlarmSeverity sev = AlarmSeverity.High,
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
<!-- WP7: the real Calculation driver, so the dependency-flow integration test drives values through
|
||||
the real DependencyMuxActor → DependencyConsumerMuxAdapter → CalculationDriver path. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
<!-- Gitea #647: the real FOCAS driver, so the native-alarm end-to-end test proves a CNC alarm reaches the
|
||||
host's Part 9 condition (ForwardNativeAlarm → NativeAlarmProjector), not merely that it was raised. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user