using System; using System.Collections.Concurrent; using System.Threading.Tasks; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Alarms; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; [Trait("Category", "Unit")] public sealed class GalaxyAlarmTrackerTests { private sealed class FakeSubscriber { public readonly ConcurrentDictionary> Subs = new(); public readonly ConcurrentQueue Unsubs = new(); public readonly ConcurrentQueue<(string Tag, object Value)> Writes = new(); public bool WriteReturns { get; set; } = true; public Task Subscribe(string tag, Action cb) { Subs[tag] = cb; return Task.CompletedTask; } public Task Unsubscribe(string tag) { Unsubs.Enqueue(tag); Subs.TryRemove(tag, out _); return Task.CompletedTask; } public Task Write(string tag, object value) { Writes.Enqueue((tag, value)); return Task.FromResult(WriteReturns); } } private static Vtq Bool(bool v) => new(v, DateTime.UtcNow, 192); private static Vtq Int(int v) => new(v, DateTime.UtcNow, 192); private static Vtq Str(string v) => new(v, DateTime.UtcNow, 192); [Fact] public async Task Track_subscribes_to_four_alarm_attributes() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); await t.TrackAsync("Tank.Level.HiHi"); fake.Subs.ShouldContainKey("Tank.Level.HiHi.InAlarm"); fake.Subs.ShouldContainKey("Tank.Level.HiHi.Priority"); fake.Subs.ShouldContainKey("Tank.Level.HiHi.DescAttrName"); fake.Subs.ShouldContainKey("Tank.Level.HiHi.Acked"); t.TrackedAlarmCount.ShouldBe(1); } [Fact] public async Task Track_is_idempotent_on_repeat_call() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); await t.TrackAsync("Alarm.A"); await t.TrackAsync("Alarm.A"); t.TrackedAlarmCount.ShouldBe(1); fake.Subs.Count.ShouldBe(4); // 4 sub calls, not 8 } [Fact] public async Task InAlarm_false_to_true_fires_Active_transition() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); var transitions = new ConcurrentQueue(); t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); await t.TrackAsync("Alarm.A"); fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(500)); fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("TankLevelHiHi")); fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); transitions.Count.ShouldBe(1); transitions.TryDequeue(out var tr).ShouldBeTrue(); tr!.Transition.ShouldBe(AlarmStateTransition.Active); tr.Priority.ShouldBe(500); tr.DescAttrName.ShouldBe("TankLevelHiHi"); } [Fact] public async Task InAlarm_true_to_false_fires_Inactive_transition() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); var transitions = new ConcurrentQueue(); t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); await t.TrackAsync("Alarm.A"); fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(false)); transitions.Count.ShouldBe(2); transitions.TryDequeue(out _); transitions.TryDequeue(out var tr).ShouldBeTrue(); tr!.Transition.ShouldBe(AlarmStateTransition.Inactive); } [Fact] public async Task Acked_false_to_true_fires_Acknowledged_while_InAlarm_is_true() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); var transitions = new ConcurrentQueue(); t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); await t.TrackAsync("Alarm.A"); fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); // Active, clears Acked flag fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); // Acknowledged transitions.Count.ShouldBe(2); transitions.TryDequeue(out _); transitions.TryDequeue(out var tr).ShouldBeTrue(); tr!.Transition.ShouldBe(AlarmStateTransition.Acknowledged); } [Fact] public async Task Acked_transition_while_InAlarm_is_false_does_not_fire() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); var transitions = new ConcurrentQueue(); t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); await t.TrackAsync("Alarm.A"); // Initial Acked=true on subscribe (alarm is at rest, pre-ack'd) — should not fire. fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); transitions.Count.ShouldBe(0); } [Fact] public async Task Acknowledge_writes_AckMsg_with_comment() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); await t.TrackAsync("Alarm.A"); var ok = await t.AcknowledgeAsync("Alarm.A", "acknowledged by operator"); ok.ShouldBeTrue(); fake.Writes.Count.ShouldBe(1); fake.Writes.TryDequeue(out var w).ShouldBeTrue(); w.Tag.ShouldBe("Alarm.A.AckMsg"); w.Value.ShouldBe("acknowledged by operator"); } [Fact] public async Task Snapshot_reports_latest_fields() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); await t.TrackAsync("Alarm.A"); fake.Subs["Alarm.A.InAlarm"]("Alarm.A.InAlarm", Bool(true)); fake.Subs["Alarm.A.Priority"]("Alarm.A.Priority", Int(900)); fake.Subs["Alarm.A.DescAttrName"]("Alarm.A.DescAttrName", Str("MyAlarm")); fake.Subs["Alarm.A.Acked"]("Alarm.A.Acked", Bool(true)); var snap = t.SnapshotStates(); snap.Count.ShouldBe(1); snap[0].InAlarm.ShouldBeTrue(); snap[0].Acked.ShouldBeTrue(); snap[0].Priority.ShouldBe(900); snap[0].DescAttrName.ShouldBe("MyAlarm"); } [Fact] public async Task Foreign_probe_callback_is_dropped() { var fake = new FakeSubscriber(); using var t = new GalaxyAlarmTracker(fake.Subscribe, fake.Unsubscribe, fake.Write); var transitions = new ConcurrentQueue(); t.TransitionRaised += (_, tr) => transitions.Enqueue(tr); // No TrackAsync was called — this callback is foreign and should be silently ignored. t.OnProbeCallback("Unknown.InAlarm", Bool(true)); transitions.Count.ShouldBe(0); } }