using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; /// /// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start, /// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence. /// public class NativeAlarmActorTests : TestKit, IDisposable { private readonly string _dbFile; private readonly SiteStorageService _storage; private readonly SiteRuntimeOptions _options = new(); public NativeAlarmActorTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"naa-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService($"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); } private static ResolvedNativeAlarmSource Source() => new() { CanonicalName = "Pressure", ConnectionName = "Opc", SourceReference = "ns=2;s=T01" }; private static NativeAlarmTransition Transition( string sourceRef, AlarmTransitionKind kind, AlarmConditionState condition, DateTimeOffset? time = null) => new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition, "Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90"); private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) => ActorOf(Props.Create(() => new NativeAlarmActor( Source(), "inst", instanceActor, dclManager, _storage, _options, NullLogger.Instance, AlarmKind.NativeOpcUa, serviceProvider))); [Fact] public void SubscribeOnStart_SendsRequestForSourceBinding() { var dcl = CreateTestProbe(); Spawn(CreateTestProbe().Ref, dcl.Ref); var req = dcl.ExpectMsg(); Assert.Equal("inst", req.InstanceUniqueName); Assert.Equal("Opc", req.ConnectionName); Assert.Equal("ns=2;s=T01", req.SourceReference); } [Fact] public void Raise_EmitsEnrichedAlarmStateChanged() { var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref); dcl.ExpectMsg(); actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); var emitted = instance.ExpectMsg(); Assert.Equal(AlarmKind.NativeOpcUa, emitted.Kind); Assert.Equal("T01.Hi", emitted.SourceReference); Assert.Equal(AlarmState.Active, emitted.State); Assert.Equal(800, emitted.Condition.Severity); } [Fact] public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal() { var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref); dcl.ExpectMsg(); actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); instance.ExpectMsg(m => m.State == AlarmState.Active); // A fresh snapshot that no longer contains T01.Hi means it cleared at the source. actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.SnapshotComplete, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); var cleared = instance.ExpectMsg(); Assert.Equal("T01.Hi", cleared.SourceReference); Assert.Equal(AlarmState.Normal, cleared.State); } [Fact] public void OlderTransition_IsIgnored() { var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref); dcl.ExpectMsg(); var t0 = DateTimeOffset.UtcNow; actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0))); instance.ExpectMsg(m => m.State == AlarmState.Active); // An out-of-order (older) transition must not overwrite newer state. actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Clear, new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(-30)))); instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } // ── M1.5: site event log `alarm` category ────────────────────────────── [Fact] public void Raise_EmitsAlarmSiteEvent() { var siteLog = new FakeSiteEventLogger(); var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog)); dcl.ExpectMsg(); actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); instance.ExpectMsg(m => m.State == AlarmState.Active); AwaitAssert(() => { var rows = siteLog.OfType("alarm"); Assert.Single(rows); var row = rows[0]; Assert.Equal("Error", row.Severity); // severity 800 → Error Assert.Equal("inst", row.InstanceId); Assert.Equal("NativeAlarmActor:Pressure", row.Source); }, TimeSpan.FromSeconds(2)); } [Fact] public void Clear_EmitsInfoAlarmSiteEvent() { var siteLog = new FakeSiteEventLogger(); var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog)); dcl.ExpectMsg(); var t0 = DateTimeOffset.UtcNow; actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0))); instance.ExpectMsg(m => m.State == AlarmState.Active); // Clear (inactive but not yet acked → stays mirrored, return-to-normal emit). actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Clear, new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5)))); instance.ExpectMsg(m => m.State == AlarmState.Normal); AwaitAssert(() => { var rows = siteLog.OfType("alarm"); Assert.Equal(2, rows.Count); // raise + clear Assert.Equal("Error", rows[0].Severity); Assert.Equal("Info", rows[1].Severity); // return-to-normal → Info }, TimeSpan.FromSeconds(2)); } [Fact] public async Task Rehydration_DoesNotEmitSiteEvent() { // Pre-populate SQLite with an active condition so the actor rehydrates // it on PreStart. Rehydration replays last-known state — it is NOT a // live transition, so it must surface upward (for the DebugView) but // must NOT re-log an `alarm` operational event. var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800); await _storage.UpsertNativeAlarmAsync( "inst", "Pressure", "T01.Hi", JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow); var siteLog = new FakeSiteEventLogger(); var instance = CreateTestProbe(); var dcl = CreateTestProbe(); Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog)); // The rehydrated condition is surfaced upward... var emitted = instance.ExpectMsg(TimeSpan.FromSeconds(2)); Assert.Equal("T01.Hi", emitted.SourceReference); Assert.Equal(AlarmState.Active, emitted.State); dcl.ExpectMsg(); // ...but no `alarm` operational event is logged for it. AwaitAssert( () => Assert.Empty(siteLog.OfType("alarm")), TimeSpan.FromSeconds(1)); } [Fact] public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit() { var siteLog = new FakeSiteEventLogger(); var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog)); dcl.ExpectMsg(); // Live raise — the one and only `alarm` event we expect. actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); instance.ExpectMsg(m => m.State == AlarmState.Active); AwaitAssert(() => Assert.Single(siteLog.OfType("alarm")), TimeSpan.FromSeconds(2)); // A reconnect snapshot that RE-INCLUDES the same still-active condition is // a re-sync, not a live transition. It must NOT re-log a second `alarm` // event (regression for the spurious-reconnect-event bug). actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Snapshot, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.SnapshotComplete, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800)))); // The snapshot still surfaces the condition upward (DebugView re-sync)... instance.ExpectMsg(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Active); // ...but the `alarm` event count stays at exactly 1 — no re-emit. Thread.Sleep(200); // give any spurious fire-and-forget log time to land Assert.Single(siteLog.OfType("alarm")); } [Fact] public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged() { var siteLog = new FakeSiteEventLogger(); var instance = CreateTestProbe(); var dcl = CreateTestProbe(); var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog)); dcl.ExpectMsg(); var t0 = DateTimeOffset.UtcNow; actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Raise, new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0))); instance.ExpectMsg(m => m.State == AlarmState.Active); // Operator acknowledges the still-active condition. The Acknowledge // branch of LogAlarmEvent logs Info and mentions "acknowledged". actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition( "T01.Hi", AlarmTransitionKind.Acknowledge, new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5)))); instance.ExpectMsg(); AwaitAssert(() => { var rows = siteLog.OfType("alarm"); Assert.Equal(2, rows.Count); // raise + acknowledge var ack = rows[1]; Assert.Equal("Info", ack.Severity); Assert.Contains("acknowledged", ack.Message, StringComparison.OrdinalIgnoreCase); }, TimeSpan.FromSeconds(2)); } void IDisposable.Dispose() { Shutdown(); if (File.Exists(_dbFile)) { File.Delete(_dbFile); } } }