using System.Collections.Concurrent; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests; /// /// PR 5.1 / #316 — covers the shape on /// : feature-gating, gate event projection, multi-event /// ordering, acknowledge round-trip, and JSON DTO round-trip on the options. /// [Trait("Category", "Unit")] public sealed class TwinCATAlarmSourceTests { [Fact] public async Task EnableAlarms_false_does_not_create_alarm_source() { var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = false, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); drv.HasAlarmSource.ShouldBeFalse(); var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None); handle.ShouldBeOfType(); ((TwinCATAlarmSubscriptionHandle)handle).Id.ShouldBe(0); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task EnableAlarms_false_OnAlarmEvent_never_fires() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = false, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); var raised = new ConcurrentQueue(); drv.OnAlarmEvent += (_, e) => raised.Enqueue(e); _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); // Even if a stray event is fired through the gate (a buggy operator wired in a // fake), the disabled-mode driver doesn't subscribe + the event is dropped. gate.RaiseAlarm(new TwinCATAlarmEvent("Class.A", "Source1", 100, "msg", DateTimeOffset.UtcNow, false)); await Task.Delay(20); raised.ShouldBeEmpty(); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task EnableAlarms_true_creates_source_and_starts_gate_on_first_subscribe() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); drv.HasAlarmSource.ShouldBeTrue(); gate.StartCount.ShouldBe(0); _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); gate.StartCount.ShouldBe(1); // Second subscribe doesn't restart the gate. _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); gate.StartCount.ShouldBe(1); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Gate_event_raises_AlarmEvent_on_driver_with_correct_shape() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); var raised = new ConcurrentQueue(); drv.OnAlarmEvent += (_, e) => raised.Enqueue(e); _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); var stamp = DateTimeOffset.UtcNow; gate.RaiseAlarm(new TwinCATAlarmEvent( EventClass: "TcEventClass.MachineFault", Source: "Conveyor1.MotorOverload", Severity: 200, Message: "Motor overload tripped", OccurrenceUtc: stamp, Acked: false)); raised.Count.ShouldBe(1); var args = raised.First(); args.SourceNodeId.ShouldBe("Conveyor1.MotorOverload"); args.AlarmType.ShouldBe("TcEventClass.MachineFault"); args.Message.ShouldBe("Motor overload tripped"); args.Severity.ShouldBe(AlarmSeverity.Critical); args.SourceTimestampUtc.ShouldBe(stamp.UtcDateTime); args.ConditionId.ShouldBe("Conveyor1.MotorOverload#TcEventClass.MachineFault"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Multiple_alarm_events_are_delivered_in_order() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); var raised = new List(); drv.OnAlarmEvent += (_, e) => { lock (raised) raised.Add(e); }; _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); var t = DateTimeOffset.UtcNow; for (var i = 0; i < 5; i++) { gate.RaiseAlarm(new TwinCATAlarmEvent( "Class.X", $"Source{i}", (ushort)(50 + i * 10), $"msg{i}", t.AddMilliseconds(i), false)); } raised.Count.ShouldBe(5); for (var i = 0; i < 5; i++) raised[i].SourceNodeId.ShouldBe($"Source{i}"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task SourceFilter_only_passes_matching_source() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); var raised = new ConcurrentQueue(); drv.OnAlarmEvent += (_, e) => raised.Enqueue(e); _ = await drv.SubscribeAlarmsAsync(["Conveyor1"], CancellationToken.None); gate.RaiseAlarm(new TwinCATAlarmEvent("C", "Conveyor1", 100, "x", DateTimeOffset.UtcNow, false)); gate.RaiseAlarm(new TwinCATAlarmEvent("C", "OtherSource", 100, "y", DateTimeOffset.UtcNow, false)); gate.RaiseAlarm(new TwinCATAlarmEvent("C", "conveyor1", 100, "z", DateTimeOffset.UtcNow, false)); // case-insensitive raised.Count.ShouldBe(2); raised.ShouldAllBe(e => string.Equals(e.SourceNodeId, "Conveyor1", StringComparison.OrdinalIgnoreCase)); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Acknowledge_round_trips_to_gate() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); _ = await drv.SubscribeAlarmsAsync([], CancellationToken.None); await drv.AcknowledgeAsync( [new AlarmAcknowledgeRequest("Conveyor1", "cond-1", "operator A")], CancellationToken.None); gate.AckLog.Count.ShouldBe(1); gate.AckLog.Single().SourceNodeId.ShouldBe("Conveyor1"); gate.AckLog.Single().ConditionId.ShouldBe("cond-1"); gate.AckLog.Single().Comment.ShouldBe("operator A"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Acknowledge_when_disabled_is_noop() { var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = false, }, "drv-1"); await drv.InitializeAsync("{}", CancellationToken.None); // Should complete without throwing even though no source is wired. await drv.AcknowledgeAsync( [new AlarmAcknowledgeRequest("X", "Y", null)], CancellationToken.None); await drv.UnsubscribeAlarmsAsync(new TwinCATAlarmSubscriptionHandle(0), CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Unsubscribe_stops_event_delivery() { var gate = new FakeTwinCATAlarmGate(); var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")], Probe = new TwinCATProbeOptions { Enabled = false }, EnableAlarms = true, }, "drv-1", alarmGate: gate); await drv.InitializeAsync("{}", CancellationToken.None); var raised = new ConcurrentQueue(); drv.OnAlarmEvent += (_, e) => raised.Enqueue(e); var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None); gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "before", DateTimeOffset.UtcNow, false)); await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "after", DateTimeOffset.UtcNow, false)); raised.Count.ShouldBe(1); raised.First().Message.ShouldBe("before"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public void Severity_mapping_buckets_match_quartile_cuts() { TwinCATAlarmSource.MapSeverity(0).ShouldBe(AlarmSeverity.Low); TwinCATAlarmSource.MapSeverity(64).ShouldBe(AlarmSeverity.Low); TwinCATAlarmSource.MapSeverity(65).ShouldBe(AlarmSeverity.Medium); TwinCATAlarmSource.MapSeverity(128).ShouldBe(AlarmSeverity.Medium); TwinCATAlarmSource.MapSeverity(129).ShouldBe(AlarmSeverity.High); TwinCATAlarmSource.MapSeverity(192).ShouldBe(AlarmSeverity.High); TwinCATAlarmSource.MapSeverity(193).ShouldBe(AlarmSeverity.Critical); TwinCATAlarmSource.MapSeverity(255).ShouldBe(AlarmSeverity.Critical); } [Fact] public void Options_round_trip_preserves_EnableAlarms() { var original = new TwinCATDriverOptions { Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")], EnableAlarms = true, }; var json = JsonSerializer.Serialize(original); var restored = JsonSerializer.Deserialize(json); restored.ShouldNotBeNull(); restored.EnableAlarms.ShouldBeTrue(); var defaultRestored = JsonSerializer.Deserialize("{}"); defaultRestored.ShouldNotBeNull(); defaultRestored.EnableAlarms.ShouldBeFalse(); } /// /// Fake alarm gate — captures Start invocations + ack requests, exposes /// so tests can drive synthetic events without standing up /// a second AMS-port-110 session against a real TC3 EventLogger. /// private sealed class FakeTwinCATAlarmGate : ITwinCATAlarmGate { public int StartCount { get; private set; } public List AckLog { get; } = new(); public List ActiveAlarmsList { get; } = new(); public IReadOnlyList ActiveAlarms => ActiveAlarmsList; public event EventHandler? OnAlarmEvent; public Task StartAsync(CancellationToken cancellationToken) { StartCount++; return Task.CompletedTask; } public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { AckLog.AddRange(acknowledgements); return Task.CompletedTask; } public void RaiseAlarm(TwinCATAlarmEvent evt) => OnAlarmEvent?.Invoke(this, evt); public void Dispose() { } } }