Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory) that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default, HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on (OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport variants stay back-compat). FocasDriver now implements IAlarmSource. 13 new unit tests cover: mode switch, dedup, distinct-timestamp emission, type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass. Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new), docs/v2/implementation/focas-wire-protocol.md (new), docs/v2/implementation/focas-simulator-plan.md (new), docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended). Closes #267
297 lines
12 KiB
C#
297 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>
|
|
/// Issue #267 (plan PR F3-a) — coverage for the <c>cnc_rdalmhistry</c> alarm-history
|
|
/// extension. Asserts mode switch, dedup, timestamp passthrough, depth clamp, and
|
|
/// the back-compat ActiveOnly path.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasAlarmProjectionTests
|
|
{
|
|
private const string Device = "focas://10.0.0.5:8193";
|
|
|
|
private static FocasAlarmHistoryEntry Entry(
|
|
DateTimeOffset when, int alarmNumber, int alarmType = 1, string msg = "Spindle overload")
|
|
=> new(when, AxisNo: 1, AlarmType: alarmType, AlarmNumber: alarmNumber, Message: msg);
|
|
|
|
// ---- Mode switch -------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ActiveOnly_Mode_Does_Not_Issue_History_Poll()
|
|
{
|
|
var factory = new FakeFocasClientFactory();
|
|
var fake = new FakeFocasClient
|
|
{
|
|
AlarmHistory = { Entry(DateTimeOffset.UtcNow, 100) },
|
|
};
|
|
factory.Customise = () => fake;
|
|
|
|
var opts = new FocasDriverOptions
|
|
{
|
|
Devices = [new FocasDeviceOptions(Device)],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
AlarmProjection = new FocasAlarmProjectionOptions { Mode = FocasAlarmProjectionMode.ActiveOnly },
|
|
};
|
|
var drv = new FocasDriver(opts, "drv-active-only", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var emitted = new List<AlarmEventArgs>();
|
|
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
|
|
|
var handle = await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
handle.ShouldNotBeNull();
|
|
handle.DiagnosticId.ShouldStartWith("focas-alarm-drv-active-only-");
|
|
|
|
// Give the projection a moment — if it were polling, the fake's log would tick.
|
|
await Task.Delay(150);
|
|
fake.AlarmHistoryReadLog.ShouldBeEmpty();
|
|
emitted.ShouldBeEmpty();
|
|
|
|
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ActivePlusHistory_Mode_Polls_On_Connect_And_Emits_Entries()
|
|
{
|
|
var fake = new FakeFocasClient();
|
|
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100));
|
|
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 31, 0, TimeSpan.Zero), 200, alarmType: 2));
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5));
|
|
var drv = new FocasDriver(opts, "drv-history", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var emitted = new List<AlarmEventArgs>();
|
|
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => emitted.Count >= 2);
|
|
|
|
emitted.Count.ShouldBe(2);
|
|
emitted[0].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 30, 0, DateTimeKind.Utc));
|
|
emitted[1].SourceTimestampUtc.ShouldBe(new DateTime(2025, 4, 1, 9, 31, 0, DateTimeKind.Utc));
|
|
fake.AlarmHistoryReadLog.Count.ShouldBeGreaterThanOrEqualTo(1);
|
|
fake.AlarmHistoryReadLog[0].ShouldBe(50); // declared depth threaded through
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- Dedup -------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task Same_Entry_Across_Two_Polls_Is_Emitted_Once()
|
|
{
|
|
var fake = new FakeFocasClient();
|
|
var sameEntry = Entry(new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero), 100);
|
|
fake.AlarmHistory.Add(sameEntry);
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
|
var drv = new FocasDriver(opts, "drv-dedup", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var emitted = new List<AlarmEventArgs>();
|
|
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 3);
|
|
|
|
emitted.Count.ShouldBe(1);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Distinct_Entries_With_Different_Timestamps_Each_Emit_Once()
|
|
{
|
|
var fake = new FakeFocasClient();
|
|
// Tick 1 yields entry A.
|
|
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 100));
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
|
var drv = new FocasDriver(opts, "drv-distinct", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var emitted = new List<AlarmEventArgs>();
|
|
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => emitted.Count >= 1);
|
|
|
|
// Now add a second entry at a different timestamp + wait for the next tick.
|
|
fake.AlarmHistory.Add(Entry(new DateTimeOffset(2025, 4, 1, 9, 1, 0, TimeSpan.Zero), 200));
|
|
await WaitForAsync(() => emitted.Count >= 2);
|
|
|
|
emitted.Count.ShouldBe(2);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Same_AlarmNumber_With_Different_Type_Both_Emit()
|
|
{
|
|
// The dedup key includes type — alarm #100 type=1 and alarm #100 type=2 are distinct.
|
|
var fake = new FakeFocasClient();
|
|
var ts = new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero);
|
|
fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 1));
|
|
fake.AlarmHistory.Add(Entry(ts, 100, alarmType: 2));
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 100, interval: TimeSpan.FromMilliseconds(50));
|
|
var drv = new FocasDriver(opts, "drv-type-key", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var emitted = new List<AlarmEventArgs>();
|
|
drv.OnAlarmEvent += (_, args) => emitted.Add(args);
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => emitted.Count >= 2);
|
|
|
|
emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T1");
|
|
emitted.Select(e => e.AlarmType).ShouldContain("FOCAS_T2");
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- Timestamp passthrough --------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task OccurrenceTime_Is_The_Wire_Timestamp_Not_Now()
|
|
{
|
|
var fake = new FakeFocasClient();
|
|
var oldStamp = new DateTimeOffset(2024, 1, 15, 8, 5, 30, TimeSpan.Zero);
|
|
fake.AlarmHistory.Add(Entry(oldStamp, 100));
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 50, interval: TimeSpan.FromMinutes(5));
|
|
var drv = new FocasDriver(opts, "drv-ts", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
AlarmEventArgs? captured = null;
|
|
drv.OnAlarmEvent += (_, args) => captured = args;
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => captured is not null);
|
|
|
|
captured!.SourceTimestampUtc.ShouldBe(oldStamp.UtcDateTime);
|
|
// Sanity — must not be "Now" (more than a year stale).
|
|
(DateTime.UtcNow - captured.SourceTimestampUtc).ShouldBeGreaterThan(TimeSpan.FromDays(180));
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- HistoryDepth clamp -----------------------------------------------
|
|
|
|
[Fact]
|
|
public void ResolveDepth_Clamps_To_MaxHistoryDepth()
|
|
{
|
|
FocasAlarmProjection.ResolveDepth(500).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
|
FocasAlarmProjection.ResolveDepth(10_000).ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveDepth_Falls_Back_To_Default_When_NonPositive()
|
|
{
|
|
FocasAlarmProjection.ResolveDepth(0).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth);
|
|
FocasAlarmProjection.ResolveDepth(-1).ShouldBe(FocasAlarmProjectionOptions.DefaultHistoryDepth);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveDepth_Returns_User_Value_When_Within_Bounds()
|
|
{
|
|
FocasAlarmProjection.ResolveDepth(1).ShouldBe(1);
|
|
FocasAlarmProjection.ResolveDepth(50).ShouldBe(50);
|
|
FocasAlarmProjection.ResolveDepth(FocasAlarmProjectionOptions.MaxHistoryDepth)
|
|
.ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task User_Depth_500_Clamps_To_250_On_The_Wire()
|
|
{
|
|
var fake = new FakeFocasClient();
|
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
|
|
|
var opts = OptionsWithHistory(historyDepth: 500, interval: TimeSpan.FromMinutes(5));
|
|
var drv = new FocasDriver(opts, "drv-clamp", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
await drv.SubscribeAlarmsAsync(["AlarmRoot"], CancellationToken.None);
|
|
await WaitForAsync(() => fake.AlarmHistoryReadLog.Count >= 1);
|
|
|
|
fake.AlarmHistoryReadLog[0].ShouldBe(FocasAlarmProjectionOptions.MaxHistoryDepth);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
// ---- Decoder + encoder round-trip -------------------------------------
|
|
|
|
[Fact]
|
|
public void AlarmHistoryDecoder_RoundTrips_Through_Encode_Decode()
|
|
{
|
|
var src = new List<FocasAlarmHistoryEntry>
|
|
{
|
|
new(new DateTimeOffset(2025, 4, 1, 9, 0, 0, TimeSpan.Zero), 1, 2, 100, "Spindle"),
|
|
new(new DateTimeOffset(2025, 4, 1, 9, 5, 30, TimeSpan.Zero), 0, 4, 506, "OT axis Z"),
|
|
new(new DateTimeOffset(2025, 4, 1, 9, 6, 0, TimeSpan.Zero), 2, 1, 7, ""),
|
|
};
|
|
var bytes = FocasAlarmHistoryDecoder.Encode(src);
|
|
var decoded = FocasAlarmHistoryDecoder.Decode(bytes);
|
|
|
|
decoded.Count.ShouldBe(src.Count);
|
|
for (var i = 0; i < src.Count; i++)
|
|
{
|
|
decoded[i].OccurrenceTime.ShouldBe(src[i].OccurrenceTime);
|
|
decoded[i].AxisNo.ShouldBe(src[i].AxisNo);
|
|
decoded[i].AlarmType.ShouldBe(src[i].AlarmType);
|
|
decoded[i].AlarmNumber.ShouldBe(src[i].AlarmNumber);
|
|
decoded[i].Message.ShouldBe(src[i].Message);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmHistoryDecoder_Empty_Buffer_Yields_Empty_List()
|
|
{
|
|
FocasAlarmHistoryDecoder.Decode(ReadOnlySpan<byte>.Empty).Count.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmHistoryDecoder_Has_Stable_CommandId()
|
|
{
|
|
// Don't accidentally renumber — the simulator + Tier-C backend pin on this id.
|
|
FocasAlarmHistoryDecoder.CommandId.ShouldBe<ushort>(0x0F1A);
|
|
}
|
|
|
|
// ---- Helpers ----------------------------------------------------------
|
|
|
|
private static FocasDriverOptions OptionsWithHistory(int historyDepth, TimeSpan interval) => new()
|
|
{
|
|
Devices = [new FocasDeviceOptions(Device)],
|
|
Probe = new FocasProbeOptions { Enabled = false },
|
|
AlarmProjection = new FocasAlarmProjectionOptions
|
|
{
|
|
Mode = FocasAlarmProjectionMode.ActivePlusHistory,
|
|
HistoryDepth = historyDepth,
|
|
HistoryPollInterval = interval,
|
|
},
|
|
};
|
|
|
|
private static async Task WaitForAsync(Func<bool> condition, int timeoutMs = 5000)
|
|
{
|
|
var deadline = Environment.TickCount + timeoutMs;
|
|
while (Environment.TickCount < deadline)
|
|
{
|
|
if (condition()) return;
|
|
await Task.Delay(20);
|
|
}
|
|
condition().ShouldBeTrue("Condition not satisfied within timeout");
|
|
}
|
|
}
|