using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasAlarmProjectionTests { private const string Host = "focas://10.0.0.5:8193"; private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled) { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, AlarmProjection = new FocasAlarmProjectionOptions { Enabled = alarmsEnabled, PollInterval = TimeSpan.FromMilliseconds(30), }, }, "drv-1", factory); return (drv, factory); } [Fact] public async Task Subscribe_without_Enable_throws_NotSupported() { var (drv, _) = NewDriver(alarmsEnabled: false); await drv.InitializeAsync("{}", CancellationToken.None); await Should.ThrowAsync(() => drv.SubscribeAlarmsAsync([], CancellationToken.None)); } [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(); 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); } [Fact] public async Task Tick_diffs_raises_and_clears_without_polling_loop() { // 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(); 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()); var events = new List(); drv.OnAlarmEvent += (_, e) => events.Add(e); // 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"); } [Fact] public void Severity_mapping_matches_docs() { 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); } private static async Task WaitFor(Func pred, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (pred()) return; await Task.Delay(30); } } }