using System.Collections.Concurrent; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Server.Alarms; namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms; /// /// Server-level alarm-condition state-machine tests added in PR 2.2. Ports the live /// transition cases from GalaxyAlarmTrackerTests against the new /// driver-agnostic : sub-attribute references come /// from , value changes flow as /// instead of MX-specific Vtq, and the ack /// write path is decoupled into . /// public sealed class AlarmConditionServiceTests { private const string ConditionId = "TankFarm.Tank1.Level.HiHi"; private const string InAlarmRef = "TankFarm.Tank1.Level.HiHi.InAlarm"; private const string PriorityRef = "TankFarm.Tank1.Level.HiHi.Priority"; private const string DescRef = "TankFarm.Tank1.Level.HiHi.DescAttrName"; private const string AckedRef = "TankFarm.Tank1.Level.HiHi.Acked"; private const string AckMsgWriteRef = "TankFarm.Tank1.Level.HiHi.AckMsg"; private static AlarmConditionInfo Info( string? inAlarm = InAlarmRef, string? priority = PriorityRef, string? desc = DescRef, string? acked = AckedRef, string? ackMsg = AckMsgWriteRef) => new( SourceName: ConditionId, InitialSeverity: AlarmSeverity.Medium, InitialDescription: null, InAlarmRef: inAlarm, PriorityRef: priority, DescAttrNameRef: desc, AckedRef: acked, AckMsgWriteRef: ackMsg); private static DataValueSnapshot Bool(bool v) => new(v, StatusCode: 0, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow); private static DataValueSnapshot Int(int v) => new(v, 0, DateTime.UtcNow, DateTime.UtcNow); private static DataValueSnapshot Str(string v) => new(v, 0, DateTime.UtcNow, DateTime.UtcNow); private sealed class FakeAcker : IAlarmAcknowledger { public readonly ConcurrentQueue<(string Ref, string Comment)> Writes = new(); public bool ReturnValue { get; set; } = true; public Task WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken) { Writes.Enqueue((ackMsgWriteRef, comment)); return Task.FromResult(ReturnValue); } } [Fact] public void Track_AddsCondition_AndExposesSubscribedReferences() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.TrackedCount.ShouldBe(1); var refs = svc.GetSubscribedReferences(); refs.ShouldContain(InAlarmRef); refs.ShouldContain(PriorityRef); refs.ShouldContain(DescRef); refs.ShouldContain(AckedRef); refs.Count.ShouldBe(4); } [Fact] public void Track_IsIdempotentOnRepeatCall() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.Track(ConditionId, Info()); svc.TrackedCount.ShouldBe(1); } [Fact] public void Track_OmitsNullSubAttributeRefs() { using var svc = new AlarmConditionService(); // Driver may not expose every sub-attribute (e.g. no .Acked observable). svc.Track(ConditionId, Info(priority: null, desc: null, acked: null)); svc.GetSubscribedReferences().ShouldBe(new[] { InAlarmRef }); } [Fact] public void InAlarmFalseToTrue_FiresActiveTransition() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.Track(ConditionId, Info()); svc.OnValueChanged(PriorityRef, Int(500)); svc.OnValueChanged(DescRef, Str("Tank level high-high")); svc.OnValueChanged(InAlarmRef, Bool(true)); transitions.Count.ShouldBe(1); transitions.TryDequeue(out var t).ShouldBeTrue(); t!.Transition.ShouldBe(AlarmStateTransition.Active); t.Priority.ShouldBe(500); t.Description.ShouldBe("Tank level high-high"); t.ConditionId.ShouldBe(ConditionId); } [Fact] public void InAlarmTrueToFalse_FiresInactiveTransition() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.Track(ConditionId, Info()); svc.OnValueChanged(InAlarmRef, Bool(true)); svc.OnValueChanged(InAlarmRef, Bool(false)); transitions.Count.ShouldBe(2); transitions.TryDequeue(out _); transitions.TryDequeue(out var t).ShouldBeTrue(); t!.Transition.ShouldBe(AlarmStateTransition.Inactive); } [Fact] public void AckedFalseToTrue_FiresAcknowledged_WhileActive() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.Track(ConditionId, Info()); svc.OnValueChanged(InAlarmRef, Bool(true)); // Active, resets Acked → false svc.OnValueChanged(AckedRef, Bool(true)); // Acknowledged transitions.Count.ShouldBe(2); transitions.TryDequeue(out _); transitions.TryDequeue(out var t).ShouldBeTrue(); t!.Transition.ShouldBe(AlarmStateTransition.Acknowledged); } [Fact] public void AckedTransitionWhileInactive_DoesNotFire() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.Track(ConditionId, Info()); // Initial Acked=true on subscribe (alarm at rest, pre-ack'd) — must not fire. svc.OnValueChanged(AckedRef, Bool(true)); transitions.ShouldBeEmpty(); } [Fact] public void RepeatedActiveTransitions_ResetAckedFlag() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.Track(ConditionId, Info()); // Cycle 1: active → ack → inactive → active again svc.OnValueChanged(InAlarmRef, Bool(true)); svc.OnValueChanged(AckedRef, Bool(true)); svc.OnValueChanged(InAlarmRef, Bool(false)); svc.OnValueChanged(InAlarmRef, Bool(true)); // re-arms — Acked must reset to false svc.OnValueChanged(AckedRef, Bool(true)); // produces a fresh Acknowledged // Active, Acknowledged, Inactive, Active, Acknowledged transitions.Count.ShouldBe(5); var ordered = transitions.Select(t => t.Transition).ToArray(); ordered.ShouldBe(new[] { AlarmStateTransition.Active, AlarmStateTransition.Acknowledged, AlarmStateTransition.Inactive, AlarmStateTransition.Active, AlarmStateTransition.Acknowledged, }); } [Fact] public async Task AcknowledgeAsync_RoutesToAckerWithAckMsgRef() { using var svc = new AlarmConditionService(); var acker = new FakeAcker(); svc.Track(ConditionId, Info(), acker); var ok = await svc.AcknowledgeAsync(ConditionId, "operator-1: cleared", CancellationToken.None); ok.ShouldBeTrue(); acker.Writes.Count.ShouldBe(1); acker.Writes.TryDequeue(out var w).ShouldBeTrue(); w.Ref.ShouldBe(AckMsgWriteRef); w.Comment.ShouldBe("operator-1: cleared"); } [Fact] public async Task AcknowledgeAsync_ReturnsFalse_WhenConditionUntracked() { using var svc = new AlarmConditionService(); var acker = new FakeAcker(); svc.Track("OtherCondition", Info(), acker); var ok = await svc.AcknowledgeAsync(ConditionId, "comment"); ok.ShouldBeFalse(); acker.Writes.ShouldBeEmpty(); } [Fact] public async Task AcknowledgeAsync_ReturnsFalse_WhenNoAckerRegistered() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info(), acker: null); var ok = await svc.AcknowledgeAsync(ConditionId, "comment"); ok.ShouldBeFalse(); } [Fact] public async Task AcknowledgeAsync_ReturnsFalse_WhenAckMsgRefMissing() { using var svc = new AlarmConditionService(); var acker = new FakeAcker(); svc.Track(ConditionId, Info(ackMsg: null), acker); var ok = await svc.AcknowledgeAsync(ConditionId, "comment"); ok.ShouldBeFalse(); acker.Writes.ShouldBeEmpty(); } [Fact] public void Snapshot_ReportsLatestFields() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.OnValueChanged(InAlarmRef, Bool(true)); svc.OnValueChanged(PriorityRef, Int(900)); svc.OnValueChanged(DescRef, Str("MyAlarm")); svc.OnValueChanged(AckedRef, Bool(true)); var snap = svc.Snapshot(); snap.Count.ShouldBe(1); snap[0].ConditionId.ShouldBe(ConditionId); snap[0].InAlarm.ShouldBeTrue(); snap[0].Acked.ShouldBeTrue(); snap[0].Priority.ShouldBe(900); snap[0].Description.ShouldBe("MyAlarm"); } [Fact] public void OnValueChanged_ForUnknownReference_IsSilentlyIgnored() { using var svc = new AlarmConditionService(); var transitions = new ConcurrentQueue(); svc.TransitionRaised += (_, t) => transitions.Enqueue(t); svc.OnValueChanged("Some.Random.Tag.InAlarm", Bool(true)); transitions.ShouldBeEmpty(); } [Fact] public void Untrack_RemovesConditionAndReleasesReferences() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.Untrack(ConditionId); svc.TrackedCount.ShouldBe(0); svc.GetSubscribedReferences().ShouldBeEmpty(); } [Fact] public void Untrack_NonexistentConditionIsNoOp() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); Should.NotThrow(() => svc.Untrack("does-not-exist")); svc.TrackedCount.ShouldBe(1); } [Fact] public void Track_ThrowsAfterDisposal() { var svc = new AlarmConditionService(); svc.Dispose(); Should.Throw(() => svc.Track(ConditionId, Info())); } [Fact] public void OnValueChanged_AfterDisposal_IsSilentlyDropped() { var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.Dispose(); // Stale callbacks during disposal must not throw. Should.NotThrow(() => svc.OnValueChanged(InAlarmRef, Bool(true))); } [Fact] public void PriorityCoercion_AcceptsCommonNumericTypes() { using var svc = new AlarmConditionService(); svc.Track(ConditionId, Info()); svc.OnValueChanged(PriorityRef, new DataValueSnapshot((short)123, 0, null, DateTime.UtcNow)); svc.OnValueChanged(InAlarmRef, Bool(true)); var snap = svc.Snapshot()[0]; snap.Priority.ShouldBe(123); } }