using System.Collections.Immutable; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; /// /// Round-trip + upsert + lifecycle coverage for , /// the EF-backed over the ScriptedAlarmState table. /// public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase { private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory db) { db = NewInMemoryDbFactory(); return new EfAlarmConditionStateStore(db, NullLogger.Instance); } private static AlarmConditionState Sample( string alarmId, AlarmActiveState active = AlarmActiveState.Inactive, ShelvingState? shelving = null, ImmutableList? comments = null) { var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc); return new AlarmConditionState( AlarmId: alarmId, Enabled: AlarmEnabledState.Disabled, Active: active, Acked: AlarmAckedState.Unacknowledged, Confirmed: AlarmConfirmedState.Unconfirmed, Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)), LastTransitionUtc: t, LastActiveUtc: t.AddMinutes(-5), LastClearedUtc: t.AddMinutes(-1), LastAckUtc: t.AddMinutes(-2), LastAckUser: "jane", LastAckComment: "ack-comment", LastConfirmUtc: t.AddMinutes(-3), LastConfirmUser: "bob", LastConfirmComment: "confirm-comment", Comments: comments ?? ImmutableList.Empty); } /// Save then load round-trips every persisted operator-state + audit field. [Fact] public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields() { var store = NewStore(out _); var state = Sample("alarm-1"); await store.SaveAsync(state, CancellationToken.None); var loaded = await store.LoadAsync("alarm-1", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.AlarmId.ShouldBe("alarm-1"); loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed); loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc); loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc); loaded.LastAckUtc.ShouldBe(state.LastAckUtc); loaded.LastAckUser.ShouldBe("jane"); loaded.LastAckComment.ShouldBe("ack-comment"); loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc); loaded.LastConfirmUser.ShouldBe("bob"); loaded.LastConfirmComment.ShouldBe("confirm-comment"); } /// Loading an id that was never saved returns null. [Fact] public async Task LoadAsync_for_unknown_id_returns_null() { var store = NewStore(out _); var loaded = await store.LoadAsync("never-saved", CancellationToken.None); loaded.ShouldBeNull(); } /// Active is not persisted — a saved Active state loads back as Inactive. [Fact] public async Task Active_is_ignored_on_load_and_defaults_to_Inactive() { var store = NewStore(out _); await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None); var loaded = await store.LoadAsync("alarm-active", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Active.ShouldBe(AlarmActiveState.Inactive); } /// LastActiveUtc / LastClearedUtc have no columns and default to null on load. [Fact] public async Task Derived_timestamps_default_to_null_on_load() { var store = NewStore(out _); await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None); var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.LastActiveUtc.ShouldBeNull(); loaded.LastClearedUtc.ShouldBeNull(); } /// Unshelved round-trips with a null UnshelveAtUtc. [Fact] public async Task Unshelved_round_trips() { var store = NewStore(out _); await store.SaveAsync( Sample("alarm-unshelved", shelving: ShelvingState.Unshelved), CancellationToken.None); var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); } /// OneShot shelving round-trips. [Fact] public async Task OneShot_shelving_round_trips() { var store = NewStore(out _); await store.SaveAsync( Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)), CancellationToken.None); var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot); loaded.Shelving.UnshelveAtUtc.ShouldBeNull(); } /// The append-only comment trail round-trips all four fields per comment. [Fact] public async Task Comments_round_trip_all_fields() { var store = NewStore(out _); var c1 = new AlarmComment( new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"); var c2 = new AlarmComment( new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear"); var comments = ImmutableList.Create(c1, c2); await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None); var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Comments.Count.ShouldBe(2); loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc); loaded.Comments[0].User.ShouldBe("jane"); loaded.Comments[0].Kind.ShouldBe("Acknowledge"); loaded.Comments[0].Text.ShouldBe("checking pump"); loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc); loaded.Comments[1].User.ShouldBe("bob"); loaded.Comments[1].Kind.ShouldBe("Confirm"); loaded.Comments[1].Text.ShouldBe("all clear"); } /// An empty comment list round-trips as an empty list. [Fact] public async Task Empty_comments_round_trip() { var store = NewStore(out _); await store.SaveAsync( Sample("alarm-empty-comments", comments: ImmutableList.Empty), CancellationToken.None); var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.Comments.ShouldBeEmpty(); } /// Saving the same id twice is an upsert — one row, latest values win. [Fact] public async Task SaveAsync_twice_upserts_latest() { var store = NewStore(out var db); var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled }; var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled }; await store.SaveAsync(first, CancellationToken.None); await store.SaveAsync(second, CancellationToken.None); using (var ctx = db.CreateDbContext()) { ctx.ScriptedAlarmStates.Count(r => r.ScriptedAlarmId == "alarm-upsert").ShouldBe(1); } var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None); loaded.ShouldNotBeNull(); loaded.LastAckUser.ShouldBe("second-user"); loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled); } /// LoadAllAsync returns every saved state. [Fact] public async Task LoadAllAsync_returns_all_saved_states() { var store = NewStore(out _); await store.SaveAsync(Sample("a-1"), CancellationToken.None); await store.SaveAsync(Sample("a-2"), CancellationToken.None); await store.SaveAsync(Sample("a-3"), CancellationToken.None); var all = await store.LoadAllAsync(CancellationToken.None); all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" }); } /// RemoveAsync deletes a row so a subsequent load returns null; others survive. [Fact] public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null() { var store = NewStore(out _); await store.SaveAsync(Sample("keep"), CancellationToken.None); await store.SaveAsync(Sample("drop"), CancellationToken.None); await store.RemoveAsync("drop", CancellationToken.None); (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull(); (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull(); } /// Removing an unknown id is a no-op (does not throw). [Fact] public async Task RemoveAsync_for_unknown_id_is_noop() { var store = NewStore(out _); await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None)); } }