refactor(scripted-alarms): retire orphaned ScriptedAlarmActor + F9b evaluator (T11)
This commit is contained in:
-159
@@ -1,159 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
|
||||
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
|
||||
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
|
||||
/// AlarmPredicateContext throws on writes — predicates must stay pure).
|
||||
/// </summary>
|
||||
public sealed class RoslynScriptedAlarmEvaluatorTests
|
||||
{
|
||||
/// <summary>Captures published <see cref="ScriptLogEntry"/> records for assertion.</summary>
|
||||
private sealed class FakePublisher : IScriptLogPublisher
|
||||
{
|
||||
/// <summary>Gets the entries published so far.</summary>
|
||||
public List<ScriptLogEntry> Published { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on logging.</summary>
|
||||
private static ScriptRootLogger NoOpScriptRoot() =>
|
||||
new(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
/// <summary>Verifies evaluation of predicate returning true reports Active.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_true_reports_Active()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies evaluation of predicate returning false reports Inactive.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_returning_false_reports_Inactive()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-hi",
|
||||
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
|
||||
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies compiled predicates are cached across calls.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_predicate_across_calls()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
|
||||
|
||||
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
|
||||
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
|
||||
|
||||
first.Active.ShouldBeTrue();
|
||||
second.Active.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies compile errors return Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("compile");
|
||||
}
|
||||
|
||||
/// <summary>Verifies predicate writing virtual tag returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-bad-write",
|
||||
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("threw");
|
||||
}
|
||||
|
||||
/// <summary>Verifies empty predicate returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_empty_predicate_returns_Failure()
|
||||
{
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies evaluation after dispose returns Failure.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance, NoOpScriptRoot());
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("disposed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A predicate's <c>ctx.Logger.Warning(...)</c> call flows through the injected root script
|
||||
/// logger and out the <see cref="ScriptLogTopicSink"/>, producing one
|
||||
/// <see cref="ScriptLogEntry"/> carrying the message, the bound <c>AlarmId</c>, and the
|
||||
/// Warning level.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Script_logger_call_publishes_entry_with_bound_alarm_identity()
|
||||
{
|
||||
var publisher = new FakePublisher();
|
||||
var root = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
|
||||
.CreateLogger();
|
||||
using var sut = new RoslynScriptedAlarmEvaluator(
|
||||
NullLogger<RoslynScriptedAlarmEvaluator>.Instance, new ScriptRootLogger(root));
|
||||
|
||||
var result = sut.Evaluate(
|
||||
alarmId: "alarm-log",
|
||||
predicate: "ctx.Logger.Warning(\"alarm log\"); return true;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Active.ShouldBeTrue();
|
||||
publisher.Published.Count.ShouldBe(1);
|
||||
var entry = publisher.Published[0];
|
||||
entry.Message.ShouldBe("alarm log");
|
||||
entry.AlarmId.ShouldBe("alarm-log");
|
||||
entry.ScriptId.ShouldBe("alarm-log");
|
||||
entry.Level.ShouldBe("Warning");
|
||||
}
|
||||
}
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
|
||||
{
|
||||
/// <summary>Verifies that full state cycle publishes StateChanged messages to parent at each transition.</summary>
|
||||
[Fact]
|
||||
public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
|
||||
var t1 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t1.State.ShouldBe(ScriptedAlarmActorState.Active);
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe"));
|
||||
var t2 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionCleared());
|
||||
var t3 = parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that duplicate ConditionMet messages in Active state are ignored.</summary>
|
||||
[Fact]
|
||||
public void Duplicate_ConditionMet_in_Active_is_ignored()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("first"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("second"));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.</summary>
|
||||
[Fact]
|
||||
public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig(
|
||||
AlarmId: "alarm-7",
|
||||
AlarmName: "High Temp",
|
||||
EquipmentPath: "/site-1/line-A/oven",
|
||||
Severity: 800,
|
||||
Predicate: "temp > 80");
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config,
|
||||
evaluator: new ThresholdEvaluator(80),
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Active);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var transitionEvt = capture.Payloads.OfType<AlarmTransitionEvent>().SingleOrDefault();
|
||||
transitionEvt.ShouldNotBeNull();
|
||||
transitionEvt.AlarmId.ShouldBe("alarm-7");
|
||||
transitionEvt.AlarmName.ShouldBe("High Temp");
|
||||
transitionEvt.EquipmentPath.ShouldBe("/site-1/line-A/oven");
|
||||
transitionEvt.Severity.ShouldBe(800);
|
||||
transitionEvt.TransitionKind.ShouldBe("Activated");
|
||||
transitionEvt.User.ShouldBe("system");
|
||||
|
||||
var log = capture.Payloads.OfType<ScriptLogEntry>().SingleOrDefault();
|
||||
log.ShouldNotBeNull();
|
||||
log.AlarmId.ShouldBe("alarm-7");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that clear transition publishes Cleared event.</summary>
|
||||
[Fact]
|
||||
public void Engine_clear_transition_publishes_Cleared_event()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("alarm-7", "High Temp", "/p", 500, "temp > 80");
|
||||
var evaluator = new ThresholdEvaluator(80);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config, evaluator,
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var kinds = capture.Payloads.OfType<AlarmTransitionEvent>().Select(e => e.TransitionKind).ToList();
|
||||
kinds.ShouldContain("Activated");
|
||||
kinds.ShouldContain("Cleared");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that manual acknowledge emits Acknowledged transition with the user.</summary>
|
||||
[Fact]
|
||||
public void Manual_acknowledge_emits_Acknowledged_transition_with_user()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump Fail", "/eq", 700, Predicate: null);
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
|
||||
config, evaluator: null,
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("driver-fault"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var ackEvt = capture.Payloads.OfType<AlarmTransitionEvent>()
|
||||
.SingleOrDefault(e => e.TransitionKind == "Acknowledged");
|
||||
ackEvt.ShouldNotBeNull();
|
||||
ackEvt.User.ShouldBe("operator-jane");
|
||||
}, duration: TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
/// <summary>A threshold-based alarm evaluator for testing.</summary>
|
||||
private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
private readonly double _threshold;
|
||||
|
||||
/// <summary>Initializes a new instance of the ThresholdEvaluator class.</summary>
|
||||
/// <param name="threshold">The threshold value to compare against.</param>
|
||||
public ThresholdEvaluator(double threshold) { _threshold = threshold; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
if (!deps.TryGetValue("temp", out var raw) || raw is null)
|
||||
return ScriptedAlarmEvalResult.Failure("missing temp");
|
||||
return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A test publisher that captures published messages.</summary>
|
||||
private sealed class CapturingPublisher
|
||||
{
|
||||
/// <summary>Gets the topics that messages were published to.</summary>
|
||||
public ConcurrentBag<string> Topics { get; } = new();
|
||||
|
||||
/// <summary>Gets the payloads that were published.</summary>
|
||||
public ConcurrentBag<object> Payloads { get; } = new();
|
||||
|
||||
/// <summary>Publishes a message to the specified topic.</summary>
|
||||
/// <param name="topic">The topic name.</param>
|
||||
/// <param name="payload">The message payload.</param>
|
||||
public void Publish(string topic, object payload)
|
||||
{
|
||||
Topics.Add(topic);
|
||||
Payloads.Add(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user