From 4f4cdd05ec0153f8dd86984e56f805d4822687fb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 12:19:44 -0400 Subject: [PATCH] feat(mesh-phase4): LocalDb alarm-condition-state store (replicated, pair-local) Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- .../AlarmConditionStateSchema.cs | 82 +++++ .../Configuration/LocalDbSetup.cs | 7 +- .../LocalDbAlarmConditionStateStore.cs | 264 ++++++++++++++ .../AlarmConditionStateSchemaTests.cs | 46 +++ .../LocalDbSetupTests.cs | 14 +- .../LocalDbWiringTests.cs | 2 +- .../LocalDbAlarmConditionStateStoreTests.cs | 344 ++++++++++++++++++ 7 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs new file mode 100644 index 00000000..38b3acf5 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/AlarmConditionStateSchema.cs @@ -0,0 +1,82 @@ +using Microsoft.Data.Sqlite; + +namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; + +/// +/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the +/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit +/// trail and comment history. +/// +/// +/// +/// Replaces the central config DB's ScriptedAlarmState table as the node-local home +/// for this state (per-cluster mesh Phase 4, which cuts the ConfigDb from driver-only +/// nodes). Living in the consolidated LocalDb file is what lets the state replicate to the +/// redundant pair peer, exactly like the alarm store-and-forward buffer — see +/// , which this mirrors. +/// +/// +/// Deliberately depends on nothing but so it can be applied +/// to any connection — the host's LocalDbSetup.OnReady in production, and a bare +/// connection in a test — without dragging the DI graph along. +/// +/// +/// The primary key is the alarm identity, and Save is an unconditional upsert onto it. +/// Convergence across the pair is last-writer-wins over the primary key, handled by the +/// LocalDb replication HLC — so the store stacks no application-level last-write-wins on top, +/// the same discipline the alarm-sf sink and the deployment pointer follow. +/// +/// +/// ActiveState has no column — it is re-derived from the live predicate on startup, +/// so persisting it would only risk operators seeing a stale Active on restart. Likewise +/// LastActiveUtc / LastClearedUtc re-derive alongside it and get no columns. +/// LastTransitionUtc is carried by updated_at_utc — the row-write timestamp is +/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is +/// chronological; nullable timestamps and audit fields are stored as NULL. +/// +/// +public static class AlarmConditionStateSchema +{ + /// Table holding one condition-state row per scripted-alarm identity. + public const string StateTable = "alarm_condition_state"; + + /// + /// Creates the state table if it does not already exist. Idempotent. + /// + /// + /// An already-open connection. ILocalDb.CreateConnection() hands out open, + /// pragma-configured connections — do not call Open() on one. + /// + public static void Apply(SqliteConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + using var cmd = connection.CreateCommand(); + + // The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled, + // Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved), + // so they are NOT NULL. comments_json is never null either — an empty trail is the literal + // "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm + // has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated + // transition column). + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS alarm_condition_state ( + scripted_alarm_id TEXT NOT NULL PRIMARY KEY, + enabled_state TEXT NOT NULL, + acked_state TEXT NOT NULL, + confirmed_state TEXT NOT NULL, + shelving_state TEXT NOT NULL, + shelving_expires_utc TEXT NULL, + last_ack_user TEXT NULL, + last_ack_comment TEXT NULL, + last_ack_utc TEXT NULL, + last_confirm_user TEXT NULL, + last_confirm_comment TEXT NULL, + last_confirm_utc TEXT NULL, + comments_json TEXT NOT NULL, + updated_at_utc TEXT NOT NULL + ); + """; + cmd.ExecuteNonQuery(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs index ecbd6c8a..cd70e38c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs @@ -6,8 +6,9 @@ using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// -/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache and -/// alarm store-and-forward tables and opts them into replication. +/// The onReady callback handed to AddZbLocalDb: creates the deployment-cache, +/// alarm store-and-forward, and scripted-alarm condition-state tables and opts them into +/// replication. /// /// /// @@ -56,11 +57,13 @@ public static class LocalDbSetup { DeploymentCacheSchema.Apply(connection); AlarmSfSchema.Apply(connection); + AlarmConditionStateSchema.Apply(connection); } db.RegisterReplicated(DeploymentCacheSchema.ArtifactsTable); db.RegisterReplicated(DeploymentCacheSchema.PointerTable); db.RegisterReplicated(AlarmSfSchema.EventsTable); + db.RegisterReplicated(AlarmConditionStateSchema.StateTable); // LAST, and only here. This is the one call in OnReady that writes rows. AlarmSfLegacyMigrator.Migrate(db, configuration); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs new file mode 100644 index 00000000..a47b7e65 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/LocalDbAlarmConditionStateStore.cs @@ -0,0 +1,264 @@ +using System.Collections.Immutable; +using System.Globalization; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; + +/// +/// Node-local backed by the replicated +/// table in the node's consolidated LocalDb. +/// Maps the full Part 9 — Enabled / Acked / Confirmed / +/// Shelving + the ack/confirm audit trail + operator comments. +/// +/// +/// +/// The LocalDb replacement for EfAlarmConditionStateStore (which persisted to the +/// central config DB's ScriptedAlarmState table). Per-cluster mesh Phase 4 cuts the +/// ConfigDb from driver-only nodes, so this state moves into the same replicated LocalDb +/// file the Phase-2 alarm store-and-forward buffer already lives in — a node's condition +/// state then mirrors to its redundant pair peer instead of depending on central SQL. +/// +/// +/// The round-trip is byte-identical to the EF store — the enum↔string mappers and the +/// comment serialization below are duplicated verbatim from it (it is deleted in a later +/// task; the ported round-trip tests prove parity). The persistence rules carry over +/// unchanged: +/// +/// +/// ActiveState is NOT persisted — there is no column; on it is +/// restored as and the engine re-derives it from the +/// live predicate. LastTransitionUtc ↔ updated_at_utc: no dedicated transition column, +/// so the last transition rides the row-write timestamp. LastActiveUtc / LastClearedUtc +/// have no columns and default to null on load. +/// serializes to/from comments_json; an empty list round-trips as "[]". +/// +/// +/// Save is a single unconditional PK upsert. No application-level last-write-wins or +/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an +/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the +/// alarm-sf sink and the deployment pointer follow). +/// +/// +public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + // Fixed column order shared by every read — the reader map below indexes by these ordinals. + private const string SelectColumns = + "scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " + + "shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " + + "last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc"; + + private readonly ILocalDb _db; + private readonly ILogger _logger; + + /// Initializes a new instance of the class. + /// + /// The node's local database. Its table + /// must already exist and be registered for replication — the host does both in + /// LocalDbSetup.OnReady, before any consumer can resolve this store. + /// + /// The logger instance. + public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger logger) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task LoadAsync(string alarmId, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(alarmId); + var rows = await _db.QueryAsync( + $"SELECT {SelectColumns} FROM alarm_condition_state WHERE scripted_alarm_id = @Id", + MapRow, + new { Id = alarmId }, + ct).ConfigureAwait(false); + return rows.Count > 0 ? rows[0] : null; + } + + /// + public async Task> LoadAllAsync(CancellationToken ct) + { + var rows = await _db.QueryAsync( + $"SELECT {SelectColumns} FROM alarm_condition_state", + MapRow, + parameters: null, + ct).ConfigureAwait(false); + return rows; + } + + /// + public async Task SaveAsync(AlarmConditionState state, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(state); + + // A single unconditional upsert keyed on the alarm identity. Convergence across the pair is + // last-writer-wins over the primary key, handled by the LocalDb replication HLC — no + // app-level LWW here (see the class remarks). + await _db.ExecuteAsync( + """ + INSERT INTO alarm_condition_state + (scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, + shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, + last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc) + VALUES + (@Id, @Enabled, @Acked, @Confirmed, @Shelving, + @ShelvingExpires, @LastAckUser, @LastAckComment, @LastAckUtc, + @LastConfirmUser, @LastConfirmComment, @LastConfirmUtc, @CommentsJson, @UpdatedAtUtc) + ON CONFLICT(scripted_alarm_id) DO UPDATE SET + enabled_state = excluded.enabled_state, + acked_state = excluded.acked_state, + confirmed_state = excluded.confirmed_state, + shelving_state = excluded.shelving_state, + shelving_expires_utc = excluded.shelving_expires_utc, + last_ack_user = excluded.last_ack_user, + last_ack_comment = excluded.last_ack_comment, + last_ack_utc = excluded.last_ack_utc, + last_confirm_user = excluded.last_confirm_user, + last_confirm_comment = excluded.last_confirm_comment, + last_confirm_utc = excluded.last_confirm_utc, + comments_json = excluded.comments_json, + updated_at_utc = excluded.updated_at_utc + """, + new + { + Id = state.AlarmId, + Enabled = MapEnabledToColumn(state.Enabled), + Acked = MapAckedToColumn(state.Acked), + Confirmed = MapConfirmedToColumn(state.Confirmed), + Shelving = MapShelvingToColumn(state.Shelving.Kind), + ShelvingExpires = FormatNullable(state.Shelving.UnshelveAtUtc), + LastAckUser = state.LastAckUser, + LastAckComment = state.LastAckComment, + LastAckUtc = FormatNullable(state.LastAckUtc), + LastConfirmUser = state.LastConfirmUser, + LastConfirmComment = state.LastConfirmComment, + LastConfirmUtc = FormatNullable(state.LastConfirmUtc), + CommentsJson = SerializeComments(state.Comments), + // No dedicated transition column — persist LastTransitionUtc into updated_at_utc. + UpdatedAtUtc = Format(state.LastTransitionUtc), + }, + ct).ConfigureAwait(false); + + _logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId); + } + + /// + public async Task RemoveAsync(string alarmId, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(alarmId); + await _db.ExecuteAsync( + "DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id", + new { Id = alarmId }, + ct).ConfigureAwait(false); + } + + private static AlarmConditionState MapRow(SqliteDataReader r) => new( + AlarmId: r.GetString(0), + Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal) + ? AlarmEnabledState.Disabled + : AlarmEnabledState.Enabled, // unknown string → Enabled (safe default) + // Active is not persisted — the engine re-derives it from the predicate at startup. + Active: AlarmActiveState.Inactive, + Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal) + ? AlarmAckedState.Acknowledged + : AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default) + Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal) + ? AlarmConfirmedState.Confirmed + : AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default) + Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)), + // No transition column — updated_at_utc carries the last transition timestamp. + LastTransitionUtc: Parse(r.GetString(13)), + // LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load. + LastActiveUtc: null, + LastClearedUtc: null, + LastAckUtc: ParseNullable(r, 8), + LastAckUser: r.IsDBNull(6) ? null : r.GetString(6), + LastAckComment: r.IsDBNull(7) ? null : r.GetString(7), + LastConfirmUtc: ParseNullable(r, 11), + LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9), + LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10), + Comments: DeserializeComments(r.GetString(12))); + + // Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and + // the DateTimeKind.Utc round-trips exactly. + private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture); + + private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture); + + private static DateTime Parse(string value) => + DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) => + r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal)); + + private static string MapEnabledToColumn(AlarmEnabledState enabled) + => enabled == AlarmEnabledState.Enabled ? "Enabled" : "Disabled"; + + private static string MapAckedToColumn(AlarmAckedState acked) + => acked == AlarmAckedState.Acknowledged ? "Acknowledged" : "Unacknowledged"; + + private static string MapConfirmedToColumn(AlarmConfirmedState confirmed) + => confirmed == AlarmConfirmedState.Confirmed ? "Confirmed" : "Unconfirmed"; + + private static string MapShelvingToColumn(ShelvingKind kind) => kind switch + { + ShelvingKind.OneShot => "OneShotShelved", + ShelvingKind.Timed => "TimedShelved", + _ => "Unshelved", + }; + + private static ShelvingKind MapShelvingFromColumn(string column) => column switch + { + "OneShotShelved" => ShelvingKind.OneShot, + "TimedShelved" => ShelvingKind.Timed, + _ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default) + }; + + private static string SerializeComments(ImmutableList comments) + { + if (comments.IsEmpty) return "[]"; + var dtos = comments.Select(c => new CommentDto + { + // AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip; + // the engine always creates AlarmComment instances with Utc kind. + TimestampUtc = c.TimestampUtc, + User = c.User, + Kind = c.Kind, + Text = c.Text, + }); + return JsonSerializer.Serialize(dtos, JsonOptions); + } + + private static ImmutableList DeserializeComments(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return ImmutableList.Empty; + var dtos = JsonSerializer.Deserialize>(json, JsonOptions); + if (dtos is null || dtos.Count == 0) return ImmutableList.Empty; + return dtos + .Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty)) + .ToImmutableList(); + } + + /// Stable on-disk shape for a persisted in comments_json. + private sealed class CommentDto + { + /// When the comment was recorded (UTC). + public DateTime TimestampUtc { get; set; } + + /// Identity of the actor that wrote the comment. + public string? User { get; set; } + + /// Human-readable classification of the comment (Acknowledge, Confirm, …). + public string? Kind { get; set; } + + /// Operator-supplied or engine-generated comment text. + public string? Text { get; set; } + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs new file mode 100644 index 00000000..028bc49f --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/AlarmConditionStateSchemaTests.cs @@ -0,0 +1,46 @@ +using Microsoft.Data.Sqlite; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests; + +/// +/// Verifies creates its table and is idempotent — +/// the DDL depends on nothing but a , so a bare in-memory +/// connection is a faithful harness (no zb_hlc_next() UDF is involved in CREATE TABLE). +/// +[Trait("Category", "Unit")] +public sealed class AlarmConditionStateSchemaTests +{ + private static SqliteConnection OpenInMemory() + { + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + return connection; + } + + /// A single Apply creates the table. + [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); + } + + /// Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS). + [Fact] + public void Apply_is_idempotent() + { + using var connection = OpenInMemory(); + + AlarmConditionStateSchema.Apply(connection); + Should.NotThrow(() => AlarmConditionStateSchema.Apply(connection)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs index 3e45a17d..df45f06a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSetupTests.cs @@ -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() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs index 82f6c36e..a2c81f5a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbWiringTests.cs @@ -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] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs new file mode 100644 index 00000000..b2c15696 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/LocalDbAlarmConditionStateStoreTests.cs @@ -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; + +/// +/// Round-trip + upsert + lifecycle coverage for , +/// the LocalDb-backed over the replicated +/// alarm_condition_state table. +/// +/// +/// Ported verbatim from EfAlarmConditionStateStoreTests — the two stores share the +/// contract, so the assertions transfer unchanged and prove +/// parity. Only store construction differs: this suite drives a real temp-file +/// (the library has no in-memory mode, and a bare +/// would lack the zb_hlc_next() UDF the capture triggers +/// call), whereas the EF suite used an in-memory DbContext factory. +/// +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 { ["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(); + } + + 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.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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + } + + /// An empty comment list persists as the literal "[]" in the column. + [Fact] + public async Task Empty_comments_persist_as_bracket_pair() + { + var store = NewStore(); + await store.SaveAsync( + Sample("alarm-empty-literal", comments: ImmutableList.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("[]"); + } + + /// An unknown enabled string loads back as Enabled (safe default). + [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 + } + + /// Saving the same id twice is an upsert — one row, latest values win. + [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); + } + + /// LoadAllAsync returns every saved state. + [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" }); + } + + /// 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(); + 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(); + + await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None)); + } +}