Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmStatePersistenceTests.cs
T
Joseph Doherty 64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
docs: backfill XML documentation across 756 files
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
2026-05-28 08:10:17 -04:00

158 lines
7.0 KiB
C#

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
{
/// <summary>Verifies that alarm state transitions write to the state store with the correct lastAckUser value.</summary>
[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<ScriptedAlarmActor.StateChanged>();
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<ScriptedAlarmActor.StateChanged>();
AwaitAssert(() =>
{
var ackedSnap = store.Snapshots.Last(s => s.State == "Acknowledged");
ackedSnap.LastAckUser.ShouldBe("operator-jane");
}, duration: TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that actor restart restores persisted state so pending acknowledgment is not dropped.</summary>
[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<ScriptedAlarmActor.StateChanged>(TimeSpan.FromMilliseconds(500))
.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
}, duration: TimeSpan.FromSeconds(3));
}
/// <summary>Verifies that alarm boots to inactive state when no persisted state exists.</summary>
[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));
}
/// <summary>Verifies that EF-based alarm actor state store correctly persists and restores state through the config database.</summary>
[Fact]
public async Task EfAlarmActorStateStore_round_trip_persists_via_ConfigDb()
{
var db = NewInMemoryDbFactory();
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.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");
}
}
/// <summary>Verifies that loading an alarm state for a missing ID returns null.</summary>
[Fact]
public async Task EfAlarmActorStateStore_load_for_missing_id_returns_null()
{
var db = NewInMemoryDbFactory();
var ef = new EfAlarmActorStateStore(db, NullLogger<EfAlarmActorStateStore>.Instance);
var loaded = await ef.LoadAsync("never-saved", CancellationToken.None);
loaded.ShouldBeNull();
}
private sealed class RecordingStateStore : IAlarmActorStateStore
{
private readonly ConcurrentDictionary<string, AlarmActorStateSnapshot> _byId = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<AlarmActorStateSnapshot> _saves = new();
/// <summary>Gets all saved alarm state snapshots in order.</summary>
public List<AlarmActorStateSnapshot> Snapshots => _saves.ToList();
/// <summary>Loads the alarm state snapshot for the specified alarm ID.</summary>
/// <param name="alarmId">The alarm ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The alarm state snapshot if found, null otherwise.</returns>
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct)
=> Task.FromResult(_byId.TryGetValue(alarmId, out var v) ? v : null);
/// <summary>Saves the alarm state snapshot.</summary>
/// <param name="snapshot">The alarm state snapshot to save.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A completed task.</returns>
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct)
{
_byId[snapshot.AlarmId] = snapshot;
_saves.Enqueue(snapshot);
return Task.CompletedTask;
}
}
}