feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="AlarmConditionStateSchema.Apply"/> creates its table and is idempotent —
|
||||
/// the DDL depends on nothing but a <see cref="SqliteConnection"/>, so a bare in-memory
|
||||
/// connection is a faithful harness (no <c>zb_hlc_next()</c> UDF is involved in <c>CREATE TABLE</c>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmConditionStateSchemaTests
|
||||
{
|
||||
private static SqliteConnection OpenInMemory()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>A single Apply creates the table.</summary>
|
||||
[Fact]
|
||||
public void Apply_creates_the_state_table()
|
||||
{
|
||||
using var connection = OpenInMemory();
|
||||
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = @Name";
|
||||
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.StateTable);
|
||||
((long)cmd.ExecuteScalar()!).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
|
||||
[Fact]
|
||||
public void Apply_is_idempotent()
|
||||
{
|
||||
using var connection = OpenInMemory();
|
||||
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnReady_RegistersExactlyTheThreeReplicatedTables()
|
||||
public void OnReady_RegistersExactlyTheFourReplicatedTables()
|
||||
{
|
||||
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
|
||||
// (that table then never replicates). "No more" catches an accidental registration —
|
||||
@@ -54,7 +54,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var db = BuildDb();
|
||||
|
||||
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,6 +89,16 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
db.ReplicatedTables["alarm_sf_events"].PkColumns.ShouldBe(["id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmConditionState_PkIsTheScriptedAlarmId()
|
||||
{
|
||||
// One condition-state row per alarm identity. The pair's two nodes converge on the same row
|
||||
// under LWW rather than each keeping its own, and Save is an unconditional upsert onto it.
|
||||
var db = BuildDb();
|
||||
|
||||
db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmSfEventRows_EnterTheOplog()
|
||||
{
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class LocalDbWiringTests : IDisposable
|
||||
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs
|
||||
// three triggers and oplog volume on every write.
|
||||
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip + upsert + lifecycle coverage for <see cref="LocalDbAlarmConditionStateStore"/>,
|
||||
/// the LocalDb-backed <see cref="IAlarmStateStore"/> over the replicated
|
||||
/// <c>alarm_condition_state</c> table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ported verbatim from <c>EfAlarmConditionStateStoreTests</c> — the two stores share the
|
||||
/// <see cref="IAlarmStateStore"/> contract, so the assertions transfer unchanged and prove
|
||||
/// parity. Only store construction differs: this suite drives a real temp-file
|
||||
/// <see cref="ILocalDb"/> (the library has no in-memory mode, and a bare
|
||||
/// <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture triggers
|
||||
/// call), whereas the EF suite used an in-memory DbContext factory.
|
||||
/// </remarks>
|
||||
public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-state-{Guid.NewGuid():N}.db");
|
||||
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ILocalDb _db;
|
||||
|
||||
public LocalDbAlarmConditionStateStoreTests()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
||||
.Build();
|
||||
|
||||
_provider = new ServiceCollection()
|
||||
.AddZbLocalDb(configuration, db =>
|
||||
{
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
}
|
||||
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
|
||||
_db = _provider.GetRequiredService<ILocalDb>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
|
||||
// Pooled connections keep a handle open past dispose; clearing the pools first is what
|
||||
// makes the delete actually succeed.
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDbAlarmConditionStateStore NewStore()
|
||||
=> new(_db, NullLogger<LocalDbAlarmConditionStateStore>.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();
|
||||
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();
|
||||
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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>An empty comment list persists as the literal <c>"[]"</c> in the column.</summary>
|
||||
[Fact]
|
||||
public async Task Empty_comments_persist_as_bracket_pair()
|
||||
{
|
||||
var store = NewStore();
|
||||
await store.SaveAsync(
|
||||
Sample("alarm-empty-literal", comments: ImmutableList<AlarmComment>.Empty),
|
||||
CancellationToken.None);
|
||||
|
||||
using var conn = _db.CreateConnection();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-empty-literal'";
|
||||
var json = (string?)cmd.ExecuteScalar();
|
||||
|
||||
json.ShouldBe("[]");
|
||||
}
|
||||
|
||||
/// <summary>An unknown enabled string loads back as Enabled (safe default).</summary>
|
||||
[Fact]
|
||||
public async Task Unknown_enum_strings_map_to_safe_defaults()
|
||||
{
|
||||
// Write raw rows carrying garbage enum strings to prove the load-side mappers coerce them.
|
||||
using (var conn = _db.CreateConnection())
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES
|
||||
('garbage', 'nonsense', 'nonsense', 'nonsense', 'nonsense', '[]', '2026-06-10T12:00:00.0000000Z')
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var loaded = await NewStore().LoadAsync("garbage", CancellationToken.None);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Enabled.ShouldBe(AlarmEnabledState.Enabled); // unknown → Enabled
|
||||
loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged); // unknown → Unacknowledged
|
||||
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); // unknown → Unconfirmed
|
||||
loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); // unknown → Unshelved
|
||||
}
|
||||
|
||||
/// <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();
|
||||
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 conn = _db.CreateConnection())
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT COUNT(*) FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-upsert'";
|
||||
((long)cmd.ExecuteScalar()!).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();
|
||||
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();
|
||||
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();
|
||||
|
||||
await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user