using System.Collections.Concurrent; using Akka.Actor; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; public sealed class ScriptedAlarmStatePersistenceTests : RuntimeActorTestBase { [Fact] public async Task Transition_writes_to_state_store_with_lastAckUser() { var store = new RecordingStateStore(); var parent = CreateTestProbe(); var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null); var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold")); parent.ExpectMsg(); AwaitAssert(() => { store.Snapshots.Last().State.ShouldBe("Active"); store.Snapshots.Last().LastAckUser.ShouldBeNull(); }, duration: TimeSpan.FromSeconds(2)); actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane")); parent.ExpectMsg(); AwaitAssert(() => { var ackedSnap = store.Snapshots.Last(s => s.State == "Acknowledged"); ackedSnap.LastAckUser.ShouldBe("operator-jane"); }, duration: TimeSpan.FromSeconds(2)); } [Fact] public async Task PreStart_restores_persisted_state_so_restart_does_not_drop_pending_ack() { var store = new RecordingStateStore(); await store.SaveAsync(new AlarmActorStateSnapshot( AlarmId: "a-1", State: "Active", LastTransitionUtc: DateTime.UtcNow.AddMinutes(-5), LastAckUser: null), CancellationToken.None); var parent = CreateTestProbe(); var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump", "/eq", 700, Predicate: null); var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); // After PreStart's async load, the actor should be in Active — duplicate ConditionMet // is then ignored because the existing Active-state check. AwaitAssert(() => { actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-bob")); parent.ExpectMsg(TimeSpan.FromMilliseconds(500)) .State.ShouldBe(ScriptedAlarmActorState.Acknowledged); }, duration: TimeSpan.FromSeconds(3)); } [Fact] public async Task PreStart_with_no_persisted_state_boots_inactive() { var store = new RecordingStateStore(); var parent = CreateTestProbe(); var config = new ScriptedAlarmActor.AlarmConfig("never-seen", "X", "/eq", 500, Predicate: null); var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(config, stateStore: store)); // Empty store ⇒ actor sits Inactive; AcknowledgeAlarm is ignored from Inactive so no // StateChanged should arrive. await Task.Delay(200); actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("anyone")); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } [Fact] public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb() { var db = NewInMemoryDbFactory(); var ef = new EfAlarmActorStateStore(db, NullLogger.Instance); await ef.SaveAsync(new AlarmActorStateSnapshot( AlarmId: "alarm-7", State: "Active", LastTransitionUtc: DateTime.UtcNow, LastAckUser: null), CancellationToken.None); using (var ctx = db.CreateDbContext()) { var row = ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7"); row.AckedState.ShouldBe("Unacknowledged"); } // Acknowledge — same alarmId, transitions to Acknowledged. await ef.SaveAsync(new AlarmActorStateSnapshot( AlarmId: "alarm-7", State: "Acknowledged", LastTransitionUtc: DateTime.UtcNow, LastAckUser: "jane"), CancellationToken.None); var loaded = await ef.LoadAsync("alarm-7", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.State.ShouldBe("Acknowledged"); loaded.LastAckUser.ShouldBe("jane"); using (var ctx = db.CreateDbContext()) { ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-7").ShouldBe(1); ctx.ScriptedAlarmStates.Single(r => r.ScriptedAlarmId == "alarm-7").LastAckUser.ShouldBe("jane"); } } [Fact] public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null() { var db = NewInMemoryDbFactory(); var ef = new EfAlarmActorStateStore(db, NullLogger.Instance); var loaded = await ef.LoadAsync("never-saved", CancellationToken.None); loaded.ShouldBeNull(); } private sealed class RecordingStateStore : IAlarmActorStateStore { private readonly ConcurrentDictionary _byId = new(StringComparer.Ordinal); private readonly ConcurrentQueue _saves = new(); public List Snapshots => _saves.ToList(); public Task LoadAsync(string alarmId, CancellationToken ct) => Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null); public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) { _byId[snapshot.AlarmId] = snapshot; _saves.Enqueue(snapshot); return Task.CompletedTask; } } }