245 lines
9.8 KiB
C#
245 lines
9.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Round-trip + upsert + lifecycle coverage for <see cref="EfAlarmConditionStateStore"/>,
|
|
/// the EF-backed <see cref="IAlarmStateStore"/> over the <c>ScriptedAlarmState</c> table.
|
|
/// </summary>
|
|
public sealed class EfAlarmConditionStateStoreTests : RuntimeActorTestBase
|
|
{
|
|
private static EfAlarmConditionStateStore NewStore(out Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> db)
|
|
{
|
|
db = NewInMemoryDbFactory();
|
|
return new EfAlarmConditionStateStore(db, NullLogger<EfAlarmConditionStateStore>.Instance);
|
|
}
|
|
|
|
private static AlarmConditionState Sample(
|
|
string alarmId,
|
|
AlarmActiveState active = AlarmActiveState.Inactive,
|
|
ShelvingState? shelving = null,
|
|
ImmutableList<AlarmComment>? 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<AlarmComment>.Empty);
|
|
}
|
|
|
|
/// <summary>Save then load round-trips every persisted operator-state + audit field.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Loading an id that was never saved returns null.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Active is not persisted — a saved Active state loads back as Inactive.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>LastActiveUtc / LastClearedUtc have no columns and default to null on load.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Unshelved round-trips with a null UnshelveAtUtc.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>OneShot shelving round-trips.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>The append-only comment trail round-trips all four fields per comment.</summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>An empty comment list round-trips as an empty list.</summary>
|
|
[Fact]
|
|
public async Task Empty_comments_round_trip()
|
|
{
|
|
var store = NewStore(out _);
|
|
await store.SaveAsync(
|
|
Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
|
|
CancellationToken.None);
|
|
|
|
var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);
|
|
|
|
loaded.ShouldNotBeNull();
|
|
loaded.Comments.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>LoadAllAsync returns every saved state.</summary>
|
|
[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" });
|
|
}
|
|
|
|
/// <summary>RemoveAsync deletes a row so a subsequent load returns null; others survive.</summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Removing an unknown id is a no-op (does not throw).</summary>
|
|
[Fact]
|
|
public async Task RemoveAsync_for_unknown_id_is_noop()
|
|
{
|
|
var store = NewStore(out _);
|
|
|
|
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
|
|
}
|
|
}
|