Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
T
Joseph Doherty 55ae230fa4 ci(#606): Gitea-compatible trx upload + deterministic fixes for the CI-load test flakes
- v2-ci.yml: upload-artifact@v4 -> @v3 + continue-on-error (act_runner reports as GHES and
  v4 refuses GHES, failing every run); runner-capacity diagnostic step; AKKA_TEST_TIMEFACTOR=3
  on the test step.
- Runtime/ControlPlane harnesses: akka.test.timefactor from AKKA_TEST_TIMEFACTOR
  (TestTimeFactor), single-expect-default 15s; ControlPlane gains PresenceBudget.
- Leaked spinner threads: the infinite-loop timeout tests (Calculation, Host.IntegrationTests
  RoslynVirtualTagEvaluator, 3x Core.Scripting) pinned a core for the rest of the test process
  (abandoned, never stopped). Now a bounded 5 s wall-clock spin; outer compile-inclusive bound 30 s.
- Real test races fixed: ScriptedAlarmEngine push-during-drain (Inactive is also the start
  state + persist-before-update => wait for the Cleared save); DriverReconnectE2e (both nodes
  host the driver; Created[] is last-writer-wins and ForceReconnect is a no-op while
  Connecting); ControlPlaneStarterOrder (lost-race heal waited for a 60 s refresh tick).
- Fixed sleeps -> poll-for-condition: ReconnectSupervisor LastError, Modbus deadband steps
  (wait for observed reads), Modbus dispose hygiene precondition, FOCAS probe-loop log.
- Short presence budgets -> PresenceBudget in the Runtime/ControlPlane tests seen failing on
  Gitea; Calculation Settle 10 -> 30 s. No assertion removed, nothing skipped.
- infra/README.md §10 CI; stale TreatWarningsAsErrors comment in Directory.Build.props.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:44:31 -04:00

1990 lines
100 KiB
C#

using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// End-to-end engine tests: load, predicate evaluation, change-triggered
/// re-evaluation, state persistence, startup recovery, error isolation.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmEngineTests
{
private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
{
store = new InMemoryAlarmStateStore();
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
private static ScriptedAlarmDefinition Alarm(string id, string predicate,
string msg = "condition", AlarmSeverity sev = AlarmSeverity.High,
bool historizeToAveva = true) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate,
HistorizeToAveva: historizeToAveva);
/// <summary>Verifies that LoadAsync compiles the alarm predicate and subscribes to all referenced upstream tags.</summary>
[Fact]
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
eng.LoadedAlarmIds.ShouldContain("a1");
up.ActiveSubscriptionCount.ShouldBe(1);
}
/// <summary>Verifies that compile failures across multiple alarms are aggregated into a single error.</summary>
[Fact]
public async Task Compile_failures_aggregated_into_one_error()
{
var up = new FakeUpstream();
using var eng = Build(up, out _);
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await eng.LoadAsync([
Alarm("bad1", "return unknownIdentifier;"),
Alarm("good", "return true;"),
Alarm("bad2", "var x = alsoUnknown; return x;"),
], TestContext.Current.CancellationToken));
ex.Message.ShouldContain("2 alarm(s) did not compile");
}
/// <summary>Verifies that an upstream tag change triggers predicate re-evaluation and emits an Activated event.</summary>
[Fact]
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await WaitForAsync(() => events.Count > 0);
events[0].AlarmId.ShouldBe("HighTemp");
events[0].Emission.ShouldBe(EmissionKind.Activated);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
}
/// <summary>Verifies that clearing an upstream tag value emits a Cleared event and transitions the alarm to Inactive.</summary>
[Fact]
public async Task Clearing_upstream_emits_Cleared_event()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Startup sees 150 → active.
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
/// <summary>Verifies that the message template resolves current tag values at the moment of alarm emission.</summary>
[Fact]
public async Task Message_template_resolves_tag_values_at_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
up.Set("Limit", 100);
using var eng = Build(up, out _);
await eng.LoadAsync([
new ScriptedAlarmDefinition(
"HighTemp", "Plant/Line1", "HighTemp",
AlarmKind.LimitAlarm, AlarmSeverity.High,
"Temp {Temp}C exceeded limit {Limit}C",
"""return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
], TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await WaitForAsync(() => events.Any());
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
}
/// <summary>Verifies that AcknowledgeAsync records the operator user and persists the ack state to the store.</summary>
[Fact]
public async Task Ack_records_user_and_persists_to_store()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out var store);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
persisted.ShouldNotBeNull();
persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
persisted.LastAckUser.ShouldBe("alice");
persisted.LastAckComment.ShouldBe("checking");
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
}
/// <summary>Verifies that startup recovery restores the persisted ack state but re-derives the active state from the live predicate.</summary>
[Fact]
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate will go false on second load
// First run — alarm goes active + operator acks.
using (var eng1 = Build(up, out var sharedStore))
{
up.Set("Temp", 150);
await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
}
// Simulate restart — temp is back to 50 (below threshold).
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var store2 = new InMemoryAlarmStateStore();
// seed store2 with the acked state from before restart
await store2.SaveAsync(new AlarmConditionState(
"HighTemp",
AlarmEnabledState.Enabled,
AlarmActiveState.Active, // was active pre-restart
AlarmAckedState.Acknowledged, // ack persisted
AlarmConfirmedState.Unconfirmed,
ShelvingState.Unshelved,
DateTime.UtcNow,
DateTime.UtcNow, null,
DateTime.UtcNow, "alice", null,
null, null, null,
[new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
TestContext.Current.CancellationToken);
using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var s = eng2.GetState("HighTemp")!;
s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
s.LastAckUser.ShouldBe("alice");
}
/// <summary>Verifies that a shelved alarm transitions its internal state on activation but suppresses the Activated emission.</summary>
[Fact]
public async Task Shelved_active_transitions_state_but_suppresses_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await Task.Delay(200);
events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
"OneShot shelve suppresses activation emission");
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
"state still advances so startup recovery is consistent");
}
/// <summary>Verifies that a runtime exception thrown by a predicate script leaves the alarm state unchanged and does not affect other alarms.</summary>
[Fact]
public async Task Predicate_runtime_exception_does_not_transition_state()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out _);
await eng.LoadAsync([
Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// Bad script doesn't activate + doesn't disable other alarms.
eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
}
/// <summary>Verifies that a disabled alarm does not activate on predicate change and resumes normally after being re-enabled.</summary>
[Fact]
public async Task Disable_prevents_activation_until_re_enabled()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
up.Push("Temp", 150);
await Task.Delay(100);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
"disabled alarm ignores predicate");
await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
up.Push("Temp", 160);
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
}
/// <summary>Verifies that AddCommentAsync appends to the audit trail without changing the alarm's active or ack state.</summary>
[Fact]
public async Task AddComment_appends_to_audit_without_state_change()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out var store);
await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
s.ShouldNotBeNull();
s!.Comments.Count.ShouldBe(1);
s.Comments[0].User.ShouldBe("alice");
s.Comments[0].Kind.ShouldBe("AddComment");
}
/// <summary>Verifies that predicate scripts are forbidden from calling SetVirtualTag, and that the exception is isolated without state change.</summary>
[Fact]
public async Task Predicate_scripts_cannot_SetVirtualTag()
{
var up = new FakeUpstream();
up.Set("Temp", 100);
using var eng = Build(up, out _);
// The script compiles fine but throws at runtime when SetVirtualTag is called.
// The engine swallows the exception + leaves state unchanged.
await eng.LoadAsync([
new ScriptedAlarmDefinition(
"Bad", "Plant/Line1", "Bad",
AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
"""
ctx.SetVirtualTag("NotAllowed", 1);
return true;
"""),
], TestContext.Current.CancellationToken);
// Bad alarm's predicate threw — state unchanged.
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
/// <summary>Verifies that disposing the engine releases all upstream tag subscriptions.</summary>
[Fact]
public async Task Dispose_releases_upstream_subscriptions()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
up.ActiveSubscriptionCount.ShouldBe(1);
eng.Dispose();
up.ActiveSubscriptionCount.ShouldBe(0);
}
/// <summary>Verifies that concurrent reads of alarm state during dictionary mutations do not throw (regression for Core.ScriptedAlarms-001).</summary>
[Fact]
public async Task Concurrent_reads_during_mutation_do_not_throw(/* Core.ScriptedAlarms-001 */)
{
// Regression for Core.ScriptedAlarms-001: GetState / GetAllStates /
// LoadedAlarmIds touch _alarms from arbitrary threads with no lock while
// ApplyAsync / ReevaluateAsync reassign dictionary entries under _evalGate.
// With a plain Dictionary this race throws InvalidOperationException or
// returns torn state; with a ConcurrentDictionary the reads are safe.
var up = new FakeUpstream();
const int alarmCount = 40;
var defs = new List<ScriptedAlarmDefinition>();
for (var i = 0; i < alarmCount; i++)
{
up.Set($"Temp{i}", 50);
defs.Add(Alarm($"A{i}", $$"""return (int)ctx.GetTag("Temp{{i}}").Value > 100;"""));
}
using var eng = Build(up, out _);
await eng.LoadAsync(defs, TestContext.Current.CancellationToken);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
Exception? readerFailure = null;
// Writer: hammer the engine with state mutations that reassign _alarms entries.
var writer = Task.Run(async () =>
{
var round = 0;
while (!cts.IsCancellationRequested)
{
var id = $"A{round % alarmCount}";
await eng.AddCommentAsync(id, "tester", $"r{round}", CancellationToken.None);
up.Push($"Temp{round % alarmCount}", round % 2 == 0 ? 150 : 50);
round++;
}
});
// Readers: continuously touch the three unguarded read paths.
var readers = Enumerable.Range(0, 4).Select(_reader => Task.Run(() =>
{
try
{
while (!cts.IsCancellationRequested)
{
_ = eng.LoadedAlarmIds.Count;
_ = eng.GetAllStates().Count;
for (var i = 0; i < alarmCount; i++)
_ = eng.GetState($"A{i}");
}
}
catch (Exception ex)
{
readerFailure = ex;
}
})).ToArray();
await Task.WhenAll([writer, .. readers]);
readerFailure.ShouldBeNull(
"concurrent reads of _alarms while it is being mutated must not throw");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-012: coverage gaps
// -------------------------------------------------------------------------
// (1) Timed-shelve auto-expiry driven by the engine's shelving timer via an
// injectable clock — the clock and scriptTimeout constructor parameters
// exist for exactly this.
/// <summary>Verifies that a timed shelve automatically expires when the engine's shelving check runs past the unshelve time.</summary>
[Fact]
public async Task TimedShelve_auto_expires_when_engine_shelving_check_runs(/* -012 (1) */)
{
// Use a controllable clock; start it at T0 so we can advance it manually.
var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
Func<DateTime> clock = () => now;
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var store = new InMemoryAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger, clock);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Shelve for 10 minutes from now.
var unshelveAt = now.AddMinutes(10);
await eng.TimedShelveAsync("HighTemp", "alice", unshelveAt, TestContext.Current.CancellationToken);
eng.GetState("HighTemp")!.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// Advance clock past the unshelve time and invoke RunShelvingCheck directly
// (the 5-second real timer would be non-deterministic in tests).
now = now.AddMinutes(11);
eng.RunShelvingCheckForTest();
await WaitForAsync(() => eng.GetState("HighTemp")!.Shelving.Kind == ShelvingKind.Unshelved);
eng.GetState("HighTemp")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "timed shelve expired");
events.Any(e => e.Emission == EmissionKind.Unshelved).ShouldBeTrue("Unshelved event emitted");
}
// (2a) ConfirmAsync end-to-end through the engine.
/// <summary>Verifies that ConfirmAsync records the confirming user and emits a Confirmed event persisted to the store.</summary>
[Fact]
public async Task ConfirmAsync_records_user_and_emits_Confirmed(/* -012 (2) */)
{
var up = new FakeUpstream();
up.Set("Temp", 150);
using var eng = Build(up, out var store);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// activate → ack → clear → confirm
await eng.AcknowledgeAsync("A", "alice", null, TestContext.Current.CancellationToken);
up.Push("Temp", 50);
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.ConfirmAsync("A", "bob", "resolved", TestContext.Current.CancellationToken);
eng.GetState("A")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
eng.GetState("A")!.LastConfirmUser.ShouldBe("bob");
events.Any(e => e.Emission == EmissionKind.Confirmed).ShouldBeTrue();
var persisted = await store.LoadAsync("A", TestContext.Current.CancellationToken);
persisted!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
}
/// <summary>Verifies the emitted <see cref="ScriptedAlarmEvent.Comment"/> carries the operator's
/// ack comment on the Acknowledged emission while the earlier Activated emission has no comment,
/// and that an explicit AddComment emission carries the comment text. (historian feed prep)</summary>
[Fact]
public async Task Emission_carries_operator_Comment_on_ack_and_add_comment()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// Activate → Acknowledge(comment) → AddComment(text).
up.Push("Temp", 150);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
await eng.AcknowledgeAsync("HighTemp", "op1", "looking into it", TestContext.Current.CancellationToken);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Acknowledged));
await eng.AddCommentAsync("HighTemp", "op2", "second look", TestContext.Current.CancellationToken);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.CommentAdded));
// Engine-driven Activated emission carries no operator comment.
events.First(e => e.Emission == EmissionKind.Activated).Comment.ShouldBeNull();
// Acknowledged emission carries the ack comment.
events.First(e => e.Emission == EmissionKind.Acknowledged).Comment.ShouldBe("looking into it");
// CommentAdded emission carries the latest appended comment text.
events.First(e => e.Emission == EmissionKind.CommentAdded).Comment.ShouldBe("second look");
}
/// <summary>Verifies the emitted <see cref="ScriptedAlarmEvent.HistorizeToAveva"/> carries the
/// per-alarm opt-out flag from the <see cref="ScriptedAlarmDefinition"/> through to the event so the
/// historian adapter can suppress the durable write while the live alerts fan-out is unaffected.
/// An alarm defined <c>HistorizeToAveva: false</c> emits <c>false</c>; the default (<c>true</c>)
/// emits <c>true</c>.</summary>
[Fact]
public async Task Emission_carries_HistorizeToAveva_flag_from_definition()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync(
[
Alarm("OptOut", """return (int)ctx.GetTag("Temp").Value > 100;""", historizeToAveva: false),
Alarm("OptIn", """return (int)ctx.GetTag("Temp").Value > 100;""" /* default true */),
],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 150);
await WaitForAsync(() =>
events.Any(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated) &&
events.Any(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated));
events.First(e => e.AlarmId == "OptOut" && e.Emission == EmissionKind.Activated)
.HistorizeToAveva.ShouldBeFalse("opt-out alarm carries HistorizeToAveva=false on its emission");
events.First(e => e.AlarmId == "OptIn" && e.Emission == EmissionKind.Activated)
.HistorizeToAveva.ShouldBeTrue("opt-in (default) alarm carries HistorizeToAveva=true on its emission");
}
// (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine.
/// <summary>Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires.</summary>
[Fact]
public async Task TimedShelveAsync_and_UnshelveAsync_round_trip(/* -012 (2) */)
{
var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
Func<DateTime> clock = () => now;
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
using var eng = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
new ScriptLoggerFactory(logger), logger, clock);
await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);
var unshelveAt = now.AddMinutes(30);
await eng.TimedShelveAsync("A", "alice", unshelveAt, TestContext.Current.CancellationToken);
eng.GetState("A")!.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
eng.GetState("A")!.Shelving.UnshelveAtUtc.ShouldBe(unshelveAt);
// Operator unshelves manually before the timer expires.
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.UnshelveAsync("A", "bob", TestContext.Current.CancellationToken);
eng.GetState("A")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
events.Any(e => e.Emission == EmissionKind.Unshelved).ShouldBeTrue();
}
// (2c) EnableAsync end-to-end through the engine.
/// <summary>Verifies that EnableAsync transitions the alarm back to Enabled state and emits an Enabled event.</summary>
[Fact]
public async Task EnableAsync_re_enables_after_disable(/* -012 (2) */)
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
await eng.DisableAsync("A", "alice", TestContext.Current.CancellationToken);
eng.GetState("A")!.Enabled.ShouldBe(AlarmEnabledState.Disabled);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.EnableAsync("A", "bob", TestContext.Current.CancellationToken);
eng.GetState("A")!.Enabled.ShouldBe(AlarmEnabledState.Enabled);
events.Any(e => e.Emission == EmissionKind.Enabled).ShouldBeTrue();
}
// (3) OnEvent subscriber-throws isolation — a bad subscriber must not crash
// the engine or prevent subsequent alarm state transitions. The engine logs
// the exception and continues operating; any later alarm changes still work.
/// <summary>Verifies that an exception thrown by an OnEvent subscriber is isolated and does not crash the engine or prevent further state transitions.</summary>
[Fact]
public async Task OnEvent_subscriber_exception_does_not_crash_engine(/* -012 (3) */)
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Single subscriber that always throws.
eng.OnEvent += (_, _) => throw new InvalidOperationException("bad subscriber");
// The engine must not throw or get stuck when the subscriber throws.
// Up.Push triggers ReevaluateAsync → EvaluatePredicateToStateAsync → EmitEvent.
up.Push("Temp", 150);
// Wait for the engine to process the push (it will try+catch the subscriber
// throw and continue). State must advance even though the subscriber blew up.
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active);
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active,
"engine state advances even when the OnEvent subscriber threw");
// Verify the engine is still operational: a second state change must work.
up.Push("Temp", 50);
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Inactive);
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"engine keeps processing after subscriber exception");
}
// (4) IAlarmStateStore.SaveAsync failure — in-memory state must remain at the
// prior value after finding -007 fix (persist-before-update).
/// <summary>Verifies that a store SaveAsync failure leaves the in-memory alarm state at its prior value (persist-before-update invariant, finding -007).</summary>
[Fact]
public async Task Store_save_failure_leaves_in_memory_state_unchanged(/* -012 (4) */)
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var logger = new LoggerConfiguration().CreateLogger();
var failingStore = new FailOnSaveAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, failingStore, new ScriptLoggerFactory(logger), logger);
// LoadAsync seeds + persists startup state — make it succeed for now.
failingStore.FailSave = false;
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Startup evaluation activated the alarm.
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active);
// Now make every save throw.
failingStore.FailSave = true;
// Try to acknowledge — the save will fail, so the in-memory ack state must
// remain Unacknowledged (persist-before-update: -007 fix).
await Should.ThrowAsync<InvalidOperationException>(
() => eng.AcknowledgeAsync("A", "alice", null, TestContext.Current.CancellationToken));
eng.GetState("A")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged,
"in-memory state must stay at prior value when store save fails");
}
// (5) Re-entrant LoadAsync — the old timer must not keep firing after a second
// call (regression for finding -002: _shelvingTimer?.Dispose() fix).
/// <summary>Verifies that a second LoadAsync call disposes the prior shelving timer so it does not keep firing after reload (regression for finding -002).</summary>
[Fact]
public async Task Second_LoadAsync_does_not_leak_old_timer(/* -012 (5) */)
{
// Use a clock whose invocation count we can observe indirectly through
// shelving-check side effects: if the old timer leaked it would call
// RunShelvingCheck an additional time, but we assert no double-disposal
// or double-subscriptions instead.
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var subsAfterFirst = up.ActiveSubscriptionCount;
// Second load with a different alarm definition set.
await eng.LoadAsync([Alarm("B", """return (int)ctx.GetTag("Temp").Value > 200;""")],
TestContext.Current.CancellationToken);
// After reload, only "B" should be present; "A" subscriptions released.
eng.LoadedAlarmIds.ShouldContain("B");
eng.LoadedAlarmIds.ShouldNotContain("A");
// Subscriptions should match the new set only (one path "Temp" → 1 sub).
up.ActiveSubscriptionCount.ShouldBe(subsAfterFirst, "subscription count same after reload on same path");
// Engine is still functional.
up.Push("Temp", 250);
await WaitForAsync(() => eng.GetState("B")!.Active == AlarmActiveState.Active);
}
// (6) Cold-start AreInputsReady guard — null value, Bad status, and Uncertain
// status inputs are all handled correctly.
/// <summary>Verifies that AreInputsReady blocks predicate evaluation when inputs have null values or Bad status codes, while Uncertain quality is accepted.</summary>
[Fact]
public async Task AreInputsReady_blocks_evaluation_for_null_and_bad_inputs(/* -012 (6) */)
{
var up = new FakeUpstream();
// "Temp" is missing entirely (ReadTag returns BadNodeIdUnknown).
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// No tag value → bad status → AreInputsReady returns false → stays Inactive.
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"predicate not evaluated when input has Bad status");
// Now provide a bad-quality value explicitly.
up.Set("Temp", 150, statusCode: 0x80000000u); // Bad severity bit
up.Push("Temp", 150, statusCode: 0x80000000u);
await Task.Delay(100, TestContext.Current.CancellationToken);
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"predicate not evaluated when input has explicit Bad status code");
// Uncertain quality (non-zero but bit 31 clear) — should be treated as ready.
// The alarm should activate when the value is above 100 with Uncertain quality.
up.Set("Temp", 150, statusCode: 0x40000000u); // Uncertain severity
up.Push("Temp", 150, statusCode: 0x40000000u);
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active);
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Active,
"Uncertain-quality inputs are treated as ready — predicate evaluates");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-003: OnEvent emission must not block under _evalGate.
// (1) A slow subscriber must not block the gate for other alarms.
// (2) A subscriber that re-enters the engine (e.g. AcknowledgeAsync) must
// not deadlock against _evalGate. Both regressions are covered here.
// -------------------------------------------------------------------------
/// <summary>Verifies that an OnEvent subscriber can call engine methods (e.g. AcknowledgeAsync) without deadlocking against the evaluation gate (regression for Core.ScriptedAlarms-003).</summary>
[Fact]
public async Task OnEvent_subscriber_can_call_back_into_engine_without_deadlock(/* -003 */)
{
// Re-entrancy regression. When OnEvent emission was inside _evalGate, a
// subscriber that called an engine method (e.g. AcknowledgeAsync) hung
// forever because the non-reentrant SemaphoreSlim refused to re-grant
// the gate the dispatch path was still holding. After the fix, emission
// happens AFTER Release() so the subscriber's call acquires the gate
// cleanly and the operator-driven action completes.
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
try
{
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Subscriber re-enters the engine via Task.Run so the OnEvent
// dispatch thread is not blocked while waiting. Either way, with
// the fix in place AcknowledgeAsync must acquire _evalGate (the
// dispatch path released it before invoking the subscriber) and
// complete in well under the timeout.
var ackDone = new TaskCompletionSource();
eng.OnEvent += (_, e) =>
{
if (e.Emission != EmissionKind.Activated) return;
_ = Task.Run(async () =>
{
try
{
await eng.AcknowledgeAsync(e.AlarmId, "sub", null, CancellationToken.None);
ackDone.TrySetResult();
}
catch (Exception ex) { ackDone.TrySetException(ex); }
});
};
up.Push("Temp", 150);
var winner = await Task.WhenAny(ackDone.Task, Task.Delay(TimeSpan.FromSeconds(3)));
winner.ShouldBe(ackDone.Task,
"subscriber re-entering the engine must not deadlock against _evalGate");
await ackDone.Task; // surface any inner exception
eng.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
}
finally
{
eng.Dispose();
}
}
/// <summary>Verifies that OnEvent emission occurs after the evaluation gate is released, so subscribers can re-enter the engine without deadlocking (regression for Core.ScriptedAlarms-003).</summary>
[Fact]
public void OnEvent_emission_happens_outside_evalGate(/* -003 */)
{
// Direct white-box check on the gate-release ordering: AcknowledgeAsync
// emits the Acknowledged event AFTER releasing the gate. We assert that
// by observing the gate is acquirable from inside the subscriber.
// SemaphoreSlim.Wait(0) returns true only if the count > 0 (gate free).
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
try
{
eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken).GetAwaiter().GetResult();
// Drive to Active so Acknowledge has something to ack.
up.Push("Temp", 150);
// Use the same WaitForAsync that other tests use — synchronously
// here since this is a non-async test.
for (var i = 0; i < 80 && eng.GetState("HighTemp")!.Active != AlarmActiveState.Active; i++)
Thread.Sleep(25);
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
// Use reflection to peek at _evalGate so the subscriber can probe it.
var gateField = typeof(ScriptedAlarmEngine).GetField(
"_evalGate", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
gateField.ShouldNotBeNull();
var gate = (SemaphoreSlim)gateField.GetValue(eng)!;
var gateFreeInsideEmission = false;
eng.OnEvent += (_, e) =>
{
if (e.Emission != EmissionKind.Acknowledged) return;
// SemaphoreSlim.Wait(0) — non-blocking try-take. If the gate is
// free we acquire it (count back to 0); release immediately.
if (gate.Wait(0))
{
gateFreeInsideEmission = true;
gate.Release();
}
};
eng.AcknowledgeAsync("HighTemp", "alice", null, CancellationToken.None)
.GetAwaiter().GetResult();
gateFreeInsideEmission.ShouldBeTrue(
"_evalGate must be released before OnEvent fires so subscribers " +
"can call back into the engine without deadlocking");
}
finally
{
eng.Dispose();
}
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-006: Dispose must drain in-flight background tasks
// launched by OnUpstreamChange / RunShelvingCheck. Otherwise a re-evaluation
// or shelving check started just before Dispose can keep running and write
// to a (possibly disposed) store after the engine has returned.
// -------------------------------------------------------------------------
/// <summary>Verifies that Dispose blocks until in-flight background re-evaluation tasks complete, preventing the engine from outliving its store (regression for Core.ScriptedAlarms-006).</summary>
[Fact]
public async Task Dispose_drains_in_flight_reevaluation_tasks(/* -006 */)
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var slowStore = new BlockingSaveAlarmStateStore();
var eng = new ScriptedAlarmEngine(up, slowStore, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// Block the NEXT save (the one triggered by the push below).
var saveGate = new TaskCompletionSource();
slowStore.BlockNextSave = saveGate;
// Trigger a re-evaluation that will go inside _evalGate and call SaveAsync.
up.Push("Temp", 150);
// Wait until the store's SaveAsync is actually blocked.
await WaitForAsync(() => slowStore.SaveInProgress, timeoutMs: 1000);
// Dispose must wait for the in-flight reevaluation to complete rather
// than returning while a background task still runs.
var disposeTask = Task.Run(() => eng.Dispose());
// Verify Dispose does NOT complete immediately — it should block waiting
// for the in-flight task. Without the -006 fix Dispose returns straight
// away and the background reevaluation can outlive the engine.
var prematureFinish = await Task.WhenAny(disposeTask, Task.Delay(200));
prematureFinish.ShouldNotBe(disposeTask,
"Dispose must block until in-flight background tasks complete");
// Let the save complete and verify Dispose then returns.
saveGate.SetResult();
await disposeTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
slowStore.SaveInProgress.ShouldBeFalse("background task drained before Dispose returned");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-010: predicate evaluation and message-template
// resolution apply different quality bars on purpose. Predicate evaluation
// accepts Uncertain (the predicate can still inspect the value); message
// resolution renders Uncertain as "{?}" so the operator sees the doubt
// explicitly. The two policies are documented in docs/ScriptedAlarms.md.
// -------------------------------------------------------------------------
/// <summary>Verifies that Uncertain-quality inputs are accepted by the predicate but rendered as "{?}" in the operator-facing message template (Core.ScriptedAlarms-010).</summary>
[Fact]
public async Task Uncertain_quality_drives_predicate_but_renders_question_mark_in_message(/* -010 */)
{
var up = new FakeUpstream();
// Seed with Uncertain quality (severity bit 30 set, bit 31 clear).
up.Set("Temp", 150, statusCode: 0x40000000u);
using var eng = Build(up, out _);
await eng.LoadAsync([
new ScriptedAlarmDefinition(
"HighTemp", "Plant/Line1", "HighTemp",
AlarmKind.LimitAlarm, AlarmSeverity.High,
"Temp {Temp} exceeded limit",
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// Predicate evaluated (Uncertain treated as ready) → alarm Active.
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
"AreInputsReady accepts Uncertain so the predicate runs");
// But the resolved emission message must show "{?}" for the Uncertain
// tag — only Good substitutes into the operator-facing message.
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 200, statusCode: 0x40000000u); // still Uncertain
// Trigger another evaluation to get an emission (already active, so
// we need a clear → re-activate cycle). Easier: force the same path
// through a comment which emits a CommentAdded message. But comments
// don't run the template. Instead clear it then re-activate.
up.Push("Temp", 50, statusCode: 0u); // Good, predicate becomes false
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
events.Clear();
up.Push("Temp", 200, statusCode: 0x40000000u); // Uncertain, predicate true
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
// The Activated message must show {?} for the Uncertain input.
events.Single(e => e.Emission == EmissionKind.Activated).Message
.ShouldBe("Temp {?} exceeded limit",
"MessageTemplate.Resolve renders non-Good StatusCode as {?} " +
"even though predicate evaluation accepted the Uncertain value");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-008: switch Comments to ImmutableList for O(log n)
// append. The persisted runtime type must be ImmutableList<AlarmComment>
// (which still satisfies IReadOnlyList<AlarmComment> for existing
// consumers).
// -------------------------------------------------------------------------
/// <summary>Verifies that the Comments collection is an ImmutableList, enabling O(log n) append and satisfying IReadOnlyList consumers (Core.ScriptedAlarms-008).</summary>
[Fact]
public async Task Comments_collection_uses_ImmutableList_for_efficient_append(/* -008 */)
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);
// Add a comment so AppendComment runs.
await eng.AddCommentAsync("A", "alice", "note", TestContext.Current.CancellationToken);
var s = eng.GetState("A")!;
s.Comments.ShouldBeOfType<System.Collections.Immutable.ImmutableList<AlarmComment>>(
"Comments should be an ImmutableList so append is O(log n), not O(n)");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-011: TransitionResult.NoOp's reason parameter must be
// propagated, not silently discarded. The class-level remarks promise a
// diagnostic log line for no-op disabled-alarm evaluations.
// -------------------------------------------------------------------------
/// <summary>Verifies that TransitionResult.NoOp preserves the supplied diagnostic reason string for caller logging (Core.ScriptedAlarms-011).</summary>
[Fact]
public void TransitionResult_NoOp_propagates_reason(/* -011 */)
{
var fresh = AlarmConditionState.Fresh("a-1", DateTime.UtcNow);
var r = TransitionResult.NoOp(fresh, "disabled — predicate result ignored");
r.NoOpReason.ShouldBe("disabled — predicate result ignored",
"NoOp reason must be preserved on the TransitionResult so callers can log it");
}
/// <summary>Verifies that TransitionResult.None carries a null reason, distinguishing it from the NoOp factory (Core.ScriptedAlarms-011).</summary>
[Fact]
public void TransitionResult_None_carries_no_reason(/* -011 */)
{
var fresh = AlarmConditionState.Fresh("a-1", DateTime.UtcNow);
var r = TransitionResult.None(fresh);
r.NoOpReason.ShouldBeNull("None() factory has no reason — only NoOp() carries one");
}
// -------------------------------------------------------------------------
// Core.ScriptedAlarms-015: timer runs against partially-cleared _alarms on
// a failed LoadAsync reload. When a second LoadAsync throws at compile time,
// _shelvingTimer?.Dispose() (which lives AFTER the compileFailures throw in
// the pre-fix code) is skipped, so the old timer keeps firing against
// whatever _alarms was left in after the partial clear + partial recompile.
// The timer is eventually cleaned up by Dispose(), so there is no permanent
// resource leak; the observable risk is unexpected shelving-check side effects
// during the window between the failed reload and Dispose. The fix moves
// _shelvingTimer?.Dispose() to the START of the try block, alongside
// UnsubscribeFromUpstream, so the timer is always stopped before _alarms is
// cleared.
// -------------------------------------------------------------------------
/// <summary>Verifies that a failed second LoadAsync (compile errors) leaves the engine in a consistent
/// state: the old timer is eventually cleaned up by Dispose, a third successful LoadAsync restores
/// functionality, and no background task outlives the engine (Core.ScriptedAlarms-015).</summary>
[Fact]
public async Task Failed_reload_leaves_engine_recoverable_and_disposes_cleanly(/* -015 */)
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration().CreateLogger();
var store = new InMemoryAlarmStateStore();
var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
// === First load: succeeds ===
await eng.LoadAsync([Alarm("A", "return false;")], TestContext.Current.CancellationToken);
eng.LoadedAlarmIds.ShouldContain("A");
// === Second load: fails at compile time ===
// The old timer (started by the first load) now runs against whatever _alarms
// contains after the partial clear + partial recompile. _shelvingTimer?.Dispose()
// is skipped because it lives after the compileFailures throw.
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await eng.LoadAsync([Alarm("B", "return cantCompileThis___!;")],
TestContext.Current.CancellationToken));
ex.Message.ShouldContain("did not compile");
// Engine is in a partially-cleared state — A is gone, B didn't compile.
eng.LoadedAlarmIds.ShouldNotContain("A");
eng.LoadedAlarmIds.ShouldNotContain("B");
// === Third load: succeeds — engine is still operational ===
up.Set("Temp", 50);
await eng.LoadAsync([Alarm("C", "return false;")], TestContext.Current.CancellationToken);
eng.LoadedAlarmIds.ShouldContain("C");
// Dispose must complete cleanly (no background tasks outlive the engine,
// _shelvingTimer is stopped). Before the fix, the old timer from the first
// load could keep firing between the failed second load and Dispose.
// After the fix, it is stopped at the START of every LoadAsync attempt
// (successful or not), so Dispose always finds a clean timer state.
var disposeTask = Task.Run(() => eng.Dispose());
await disposeTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
// If Dispose threw or hung, the WaitAsync would surface it.
}
// -------------------------------------------------------------------------
// #478 — scripted condition Quality from worst-of-input tag quality (Layer 3)
// -------------------------------------------------------------------------
private const uint StatusUncertain = 0x40000000u;
private const uint StatusBad = 0x80000000u;
/// <summary>A transition that fires while an input is Uncertain carries that worst input quality on the
/// emitted event (so the projected snapshot doesn't clobber quality back to Good).</summary>
[Fact]
public async Task Transition_carries_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// Uncertain is "ready" (only outright Bad short-circuits), so the predicate runs and fires Activated.
up.Push("Temp", 150, StatusUncertain);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Activated));
var activated = events.First(e => e.Emission == EmissionKind.Activated);
(activated.WorstInputStatusCode >> 30).ShouldBe(1u); // Uncertain severity bucket
}
/// <summary>A Bad input freezes the condition (no transition) but the worst-quality bucket changed
/// Good→Bad, so the engine emits a standalone QualityChanged carrying Bad.</summary>
[Fact]
public async Task Bad_input_without_transition_emits_QualityChanged_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false → inactive
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u); // Bad severity bucket
// No phantom state transition accompanied the quality change.
events.Any(e => e.Emission == EmissionKind.Activated || e.Emission == EmissionKind.Cleared)
.ShouldBeFalse();
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
}
/// <summary>Restoring a Good input after a Bad one flips the bucket back and emits QualityChanged(Good).</summary>
[Fact]
public async Task Restoring_good_input_emits_QualityChanged_Good()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 50, StatusBad);
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged
&& (e.WorstInputStatusCode >> 30) >= 2u));
events.Clear();
up.Push("Temp", 50, 0u); // Good again, still below threshold
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBe(0u); // Good bucket
}
/// <summary>A value change that keeps the same quality bucket (Good→Good) emits no QualityChanged.</summary>
[Fact]
public async Task No_QualityChanged_when_bucket_unchanged()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
up.Push("Temp", 60, 0u); // Good→Good, still below threshold → no transition, no quality change
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
// -------------------------------------------------------------------------
// #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3)
// -------------------------------------------------------------------------
/// <summary>
/// A driver reporting Bad with no value counts as Bad quality.
/// <para>This is the hole #478 Finding 3 recorded. <c>WorstInputStatus</c> skipped every entry whose
/// value was null as "no data yet", which is indistinguishable from a real fault when the fault
/// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the
/// case being skipped was the case that mattered most. Before the fix this test sees no
/// QualityChanged at all.</para>
/// </summary>
[Fact]
public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from
using var eng = Build(up, out _);
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
// The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value.
up.Push("Temp", null, 0x80310000u); // BadNoCommunication
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
(q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u);
// The sub-code survives, so an operator can tell comms loss from any other fault.
q.WorstInputStatusCode.ShouldBe(0x80310000u);
}
/// <summary>
/// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed
/// does NOT drag the condition Bad.
/// <para>The falsifying control for the test above. Deleting the null-skip outright — the obvious
/// way to make a null-valued Bad count — would also make every not-yet-published input count, which
/// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its
/// first push, flooding the quality path with load-time annotations. Both tests pass only if the
/// engine keys off the never-pushed status code specifically.</para>
/// </summary>
[Fact]
public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
// "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and
// gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has.
using var eng = Build(up, out _);
// Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a
// CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it
// makes this assertion vacuous, and the first draft of this test was exactly that: it passed
// against the broken behaviour because the flash had already happened by the time it was listening.
var events = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => events.Add(e);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")],
TestContext.Current.CancellationToken);
up.Push("Temp", 60, 0u);
await Task.Delay(150, TestContext.Current.CancellationToken);
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (cond()) return;
await Task.Delay(25);
}
throw new TimeoutException("Condition did not become true in time");
}
/// <summary>
/// Waits for a blocked-Activated-save scenario to finish its follow-up pass: the Cleared transition
/// has been persisted (the second SaveAsync) AND applied in memory.
/// </summary>
/// <remarks>
/// Waiting for <c>Active == Inactive</c> alone is ambiguous here — Inactive is also the state the
/// alarm STARTED in, and the engine persists BEFORE it updates memory, so in the instant after the
/// gate is released the in-memory state still reads the pre-activation Inactive. The wait then
/// returned before the follow-up pass ran and the evaluation count read 1 (#606, a Gitea CI failure
/// in 137 ms). <see cref="CountingAlarmStateStore.SaveCount"/> counts at SaveAsync entry, so
/// "two saves started AND Inactive in memory" is only true after the Cleared update.
/// </remarks>
private static Task WaitForClearedFollowUpAsync(ScriptedAlarmEngine eng, CountingAlarmStateStore store) =>
WaitForAsync(
() => store.SaveCount >= 2 && eng.GetState("A")!.Active == AlarmActiveState.Inactive,
timeoutMs: 3000);
// -------------------------------------------------------------------------
// Test helpers
// -------------------------------------------------------------------------
/// <summary>
/// A store that can be instructed to throw on every SaveAsync call.
/// Used to exercise the persist-before-update invariant (finding -007).
/// </summary>
private sealed class FailOnSaveAlarmStateStore : IAlarmStateStore
{
private readonly InMemoryAlarmStateStore _inner = new();
/// <summary>Gets or sets a value indicating whether the next SaveAsync call should throw a simulated failure.</summary>
public bool FailSave { get; set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Saves an alarm condition state, optionally throwing if FailSave is set.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
if (FailSave) throw new InvalidOperationException("Simulated store failure");
return _inner.SaveAsync(state, ct);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
/// <summary>
/// A store whose SaveAsync can be made to block until the test signals it.
/// Used to verify Dispose drains in-flight background tasks (finding -006).
/// </summary>
private sealed class BlockingSaveAlarmStateStore : IAlarmStateStore
{
private readonly InMemoryAlarmStateStore _inner = new();
/// <summary>Gets or sets a TaskCompletionSource that, when set, blocks the next SaveAsync until signalled.</summary>
public TaskCompletionSource? BlockNextSave { get; set; }
/// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
public bool SaveInProgress { get; private set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Saves an alarm condition state, optionally blocking on BlockNextSave gate.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
var gate = BlockNextSave;
if (gate is not null)
{
BlockNextSave = null;
SaveInProgress = true;
try { await gate.Task.WaitAsync(ct).ConfigureAwait(false); }
finally { SaveInProgress = false; }
}
await _inner.SaveAsync(state, ct).ConfigureAwait(false);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
// --- Core.ScriptedAlarms-009: per-alarm evaluation-scratch reuse ---
/// <summary>Verifies that re-evaluations reuse the same read cache dictionary instance instead of allocating a new one.</summary>
[Fact]
public async Task Reevaluation_reuses_the_same_read_cache_dictionary()
{
// Pre-009 the engine allocated a fresh Dictionary<string, DataValueSnapshot>
// on every upstream-change tick; on a busy line this was a steady allocation
// stream on the hot path. The fix: one dictionary per alarm, refilled in place
// under _evalGate. Test asserts the dictionary instance is identical across
// two consecutive evaluations of the same alarm.
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
// First evaluation runs during LoadAsync. Capture the scratch reference now.
var scratchAfterLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
scratchAfterLoad.ShouldNotBeNull(
"the scratch should have been allocated during LoadAsync's initial evaluation");
// Trigger a re-evaluation by pushing an upstream change.
up.Push("Temp", 150);
await WaitForAsync(() =>
eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
var scratchAfterPush = eng.TryGetScratchReadCacheForTest("HighTemp");
ReferenceEquals(scratchAfterLoad, scratchAfterPush).ShouldBeTrue(
"the read-cache dictionary must be the *same* instance across evaluations " +
"(Core.ScriptedAlarms-009) — a per-call allocation would defeat the fix.");
scratchAfterPush!["Temp"].Value.ShouldBe(150, "refill must update the existing dictionary in place");
}
/// <summary>Verifies that re-evaluations reuse the same predicate context instance across evaluations.</summary>
[Fact]
public async Task Reevaluation_reuses_the_same_predicate_context()
{
// The context wraps the read-cache by reference; refilling the dictionary
// updates what the script sees. Reusing the context spares a per-call object
// allocation as well as the dictionary one.
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var ctxAfterLoad = eng.TryGetScratchContextForTest("HighTemp");
ctxAfterLoad.ShouldNotBeNull();
up.Push("Temp", 150);
await WaitForAsync(() =>
eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
var ctxAfterPush = eng.TryGetScratchContextForTest("HighTemp");
ReferenceEquals(ctxAfterLoad, ctxAfterPush).ShouldBeTrue(
"the AlarmPredicateContext must be reused across evaluations (Core.ScriptedAlarms-009).");
}
/// <summary>Verifies that LoadAsync clears prior evaluation scratch so new alarms use fresh scratch.</summary>
[Fact]
public async Task LoadAsync_drops_the_prior_generations_scratch()
{
// A config-publish recreates AlarmStates with potentially different Inputs +
// Loggers; reusing the prior generation's scratch would attach an outdated
// logger to the new alarm. LoadAsync must clear _scratchByAlarmId so the
// next evaluation lazily re-allocates against the fresh AlarmState.
var up = new FakeUpstream();
up.Set("Temp", 50);
using var eng = Build(up, out _);
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var scratchAfterFirstLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
scratchAfterFirstLoad.ShouldNotBeNull();
// Second LoadAsync — same alarm id, same predicate, but the scratch should be
// wiped and re-allocated on the next evaluation. (LoadAsync itself triggers a
// first evaluation, so the scratch is reborn before we look.)
await eng.LoadAsync(
[Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
TestContext.Current.CancellationToken);
var scratchAfterSecondLoad = eng.TryGetScratchReadCacheForTest("HighTemp");
scratchAfterSecondLoad.ShouldNotBeNull();
ReferenceEquals(scratchAfterFirstLoad, scratchAfterSecondLoad).ShouldBeFalse(
"LoadAsync must drop the prior generation's scratch — reuse across a publish " +
"would attach a stale Logger / Inputs to the new alarm definition.");
}
// --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---
/// <summary>Verifies that Dispose unloads the compiled predicate assembly via the script cache.</summary>
[Fact]
public void Dispose_unloads_compiled_predicate_assembly()
{
// Pre-fix the engine called ScriptEvaluator.Compile directly, so the
// emitted predicate assembly's ALC stayed loaded for the process lifetime.
// After the fix the engine routes through CompiledScriptCache; engine
// Dispose triggers cache Dispose which unloads every cached evaluator's ALC.
// Assert via WeakReference + GC that the assembly is actually reclaimed.
// Helper is sync + [NoInlining] so its locals can't be kept alive by an
// async state machine (an earlier async version of this test failed because
// the state-machine struct held the evaluator past the method-end).
var weak = CompileAlarmAndCaptureWeak();
for (int i = 0; i < 10 && weak.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
weak.IsAlive.ShouldBeFalse(
"engine Dispose must release the compiled-predicate assembly via " +
"CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
"back to calling ScriptEvaluator.Compile directly and -008's headline " +
"fix doesn't run in production.");
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference CompileAlarmAndCaptureWeak()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var eng = Build(up, out _);
// Block on LoadAsync so this helper stays synchronous — an `async Task`
// wrapper would compile to a state machine whose generated struct keeps the
// local `eng` reference alive past the method's apparent end, defeating GC.
eng.LoadAsync(
[new ScriptedAlarmDefinition(
"HighTemp", "Plant/Line1", "HighTemp",
AlarmKind.AlarmCondition, AlarmSeverity.High,
"x",
"""return (int)ctx.GetTag("Temp").Value > 100;""")],
default).GetAwaiter().GetResult();
// Reach into the engine's compile cache via reflection — the field is
// private; we only need the Assembly reference, scoped to this NoInlining
// helper so the locals die when it returns.
var weak = ExtractEmittedAssemblyWeakRef(eng);
eng.Dispose();
return weak;
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference ExtractEmittedAssemblyWeakRef(ScriptedAlarmEngine eng)
{
var cacheField = typeof(ScriptedAlarmEngine).GetField(
"_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cache = cacheField.GetValue(eng)!;
var cacheDictField = cache.GetType().GetField(
"_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
var lazy = cacheDict.Values.Cast<object>().Single();
var evaluator = lazy.GetType().GetProperty("Value")!.GetValue(lazy)!;
var del = (Delegate)evaluator.GetType().GetField(
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(evaluator)!;
return new WeakReference(del.Method.Module.Assembly);
}
// -------------------------------------------------------------------------
// Dirty-set coalescing (perf roadmap task 15).
//
// Pre-fix, every upstream push spawned its own fire-and-forget
// ReevaluateAsync, and each one queued on the single _evalGate. N pushes
// arriving during one slow evaluation therefore produced N serialized
// re-evaluations of the same alarms, all of them reading the SAME (latest)
// cached values — N-1 of them redundant by construction.
//
// Post-fix a push only marks its alarms dirty; a single drainer swaps the
// set and evaluates it once. The evaluation-count probe below is a
// ctx.Logger line inside the predicate counted by a Serilog sink: the store
// save-count alone cannot tell the two shapes apart, because the redundant
// passes produce no transition and therefore no save.
// -------------------------------------------------------------------------
/// <summary>Verifies that a storm of pushes arriving while an evaluation is in flight collapses
/// into a single further drain pass whose evaluation sees the LATEST pushed value, with a
/// falsification control proving the collapsed count comes from coalescing and not from dedup.</summary>
[Fact]
public async Task A_storm_of_pushes_coalesces_to_one_drain_pass()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var probe = new CountingSink("EVALPROBE");
var logger = new LoggerConfiguration().WriteTo.Sink(probe).CreateLogger();
var store = new CountingAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// LoadAsync itself evaluates once (startup recovery) — start counting after it.
probe.Reset();
store.ResetCount();
// Block the save the first push's evaluation performs. That evaluation then
// sits INSIDE _evalGate for as long as the test wants, which is the window
// every subsequent push has to coalesce into.
var saveGate = new TaskCompletionSource();
store.BlockNextSave = saveGate;
up.Push("Temp", 150); // evaluation #1 → Activated → blocks in SaveAsync
await WaitForAsync(() => store.SaveInProgress, timeoutMs: 3000);
// The storm: 10 further pushes while the gate is held. The LAST one drops
// back below the threshold, so "the drain evaluated the latest values" is
// observable as the alarm ending Inactive rather than Active.
for (var i = 1; i <= 9; i++) up.Push("Temp", 150 + i);
up.Push("Temp", 50);
saveGate.SetResult();
await WaitForClearedFollowUpAsync(eng, store);
probe.Count.ShouldBeLessThanOrEqualTo(2,
"11 pushes over one blocked evaluation must produce the in-flight evaluation plus at " +
"most ONE coalesced drain pass — pre-fix this was 11 serialized re-evaluations.");
store.SaveCount.ShouldBe(2,
"only genuine transitions persist: Activated (the blocked save) + Cleared (the drain).");
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"the coalesced drain must evaluate the LATEST cached values — latest-wins, never lost-update.");
// --- Falsification control -------------------------------------------
// Spaced pushes (each drain completes before the next push lands) must
// still produce one evaluation EACH. If this collapsed too, the counts
// above would be proving dedup/short-circuiting rather than coalescing.
probe.Reset();
store.ResetCount();
foreach (var v in new[] { 150, 50, 150, 50 })
{
up.Push("Temp", v);
var want = v > 100 ? AlarmActiveState.Active : AlarmActiveState.Inactive;
await WaitForAsync(() => eng.GetState("A")!.Active == want, timeoutMs: 3000);
}
probe.Count.ShouldBe(4,
"four pushes that each land on an idle engine must each be evaluated — the collapsed " +
"count in the storm above comes from coalescing, not from the engine skipping work.");
store.SaveCount.ShouldBe(4);
}
/// <summary>Verifies that a push landing while a drain pass holds the evaluation gate is not
/// dropped — the drainer loops and evaluates it in a following pass.</summary>
[Fact]
public async Task A_push_during_a_drain_is_not_lost()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var probe = new CountingSink("EVALPROBE");
var logger = new LoggerConfiguration().WriteTo.Sink(probe).CreateLogger();
var store = new CountingAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// LoadAsync evaluates AND saves once (startup recovery) — count both from here.
probe.Reset();
store.ResetCount();
var saveGate = new TaskCompletionSource();
store.BlockNextSave = saveGate;
up.Push("Temp", 150); // drain pass #1 → Activated → blocks in SaveAsync
await WaitForAsync(() => store.SaveInProgress, timeoutMs: 3000);
// Lands while the drainer is inside _evalGate: it must be recorded in the
// dirty set and picked up by the drainer's next loop iteration, NOT dropped
// because "a drain is already scheduled".
up.Push("Temp", 40);
saveGate.SetResult();
await WaitForClearedFollowUpAsync(eng, store);
probe.Count.ShouldBe(2, "the in-flight pass plus exactly one follow-up pass for the late push");
}
/// <summary>
/// Verifies the drain latch (<c>_drainScheduled</c>) is released even when an exception
/// escapes the drain loop, so a later push still starts a drainer. A latch left stuck true
/// stops evaluation for EVERY scripted alarm, permanently and silently.
/// </summary>
/// <remarks>
/// The escape is produced without touching the engine: the store's SaveAsync throws (which
/// <c>ReevaluateAsync</c> catches, as designed), and the engine logger is configured with a
/// Serilog <b>audit</b> sink that throws on that catch block's own "reevaluate failed"
/// Error() call. Audit sinks propagate by contract, so the throw leaves <c>ReevaluateAsync</c>
/// through its catch and unwinds <c>DrainDirtyAsync</c> — the only escape path the current
/// code actually has, and a real one for any deployment auditing its logs.
/// </remarks>
[Fact]
public async Task A_throw_escaping_the_drain_loop_still_releases_the_drain_latch()
{
var up = new FakeUpstream();
up.Set("Temp", 50);
var probe = new CountingSink("EVALPROBE");
var logger = new LoggerConfiguration()
.WriteTo.Sink(probe)
.AuditTo.Sink(new ThrowingSink("reevaluate failed"))
.CreateLogger();
var store = new CountingAlarmStateStore();
using var eng = new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
await eng.LoadAsync([
Alarm("A", """ctx.Logger.Information("EVALPROBE"); return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// LoadAsync itself evaluates + persists once (startup recovery) — count from zero.
probe.Reset();
store.ResetCount();
// Push #1: transition → SaveAsync throws → the catch's Error() hits the audit
// sink and throws → the exception unwinds out of the drain loop. Waiting on the
// save ATTEMPT (counted before the throw) rather than on the probe keeps the
// predicate-evaluation count below assertable.
store.ThrowOnNextSave = true;
up.Push("Temp", 150);
await WaitForAsync(() => store.SaveCount == 1, timeoutMs: 3000);
probe.Count.ShouldBe(1, "the faulted pass evaluated the predicate exactly once");
eng.GetState("A")!.Active.ShouldBe(AlarmActiveState.Inactive,
"persist-before-update: a failed save must leave the in-memory condition at the prior state");
// The pin. This push either starts a fresh drainer (latch already released) or is
// handed to the replacement drainer the unwinding pass starts (latch still held,
// dirty set non-empty) — the two orderings the race between "SaveAsync was called"
// and "the drain finished unwinding" produces, and both must evaluate it. With the
// latch stuck true, OnUpstreamChange only grows the dirty set, no predicate ever
// runs again, and the alarm silently never activates.
up.Push("Temp", 160);
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active, timeoutMs: 3000);
probe.Count.ShouldBe(2, "the push after the faulted drain must itself be evaluated");
}
/// <summary>
/// Verifies a long chain of drain-latch handovers under sustained <b>synchronous</b> failure
/// neither stalls the engine nor deepens the native stack: each replacement drainer must
/// start on a FRESH stack rather than nesting inside the finally block that spawned it.
/// </summary>
/// <remarks>
/// <para><b>The escape has to be synchronous, and picking the wrong one makes this test
/// vacuous.</b> A drain pass whose failure comes from the STORE cannot nest: the predicate
/// runs first, and <c>TimedScriptEvaluator.RunAsync</c> hops to the pool
/// (<c>Task.Run(...).WaitAsync(timeout)</c>) on every evaluation, so the drainer has already
/// suspended and returned to its caller long before the throw. Measured: depth is a flat 36
/// across 200 store-throw handovers whether the replacement is queued or called. The escape
/// used here is therefore the injected <b>clock</b>, which <c>ReevaluateAsync</c> calls at
/// the top of each alarm's iteration — under a free <c>_evalGate</c>, before any awaited
/// work — so the throw, the catch, the audit sink's rethrow and the whole unwind happen
/// without a single suspension.</para>
/// <para>The chain is deterministic rather than raced: the clock re-dirties the alarm from
/// inside the failing call (a re-entrant upstream push, which only grows the dirty set
/// because the latch is already held), so every unwinding pass finds work and hands the
/// latch to a replacement — exactly <c>Handovers</c> times, with no dependence on thread
/// timing. Depth is sampled per call: flat when the handover goes through <c>Task.Run</c>,
/// climbing monotonically when the replacement is called inline.</para>
/// </remarks>
[Fact]
public async Task Sustained_synchronous_drain_failures_hand_over_without_deepening_the_stack()
{
const int Handovers = 200;
var up = new FakeUpstream();
up.Set("Temp", 50);
var logger = new LoggerConfiguration()
.AuditTo.Sink(new ThrowingSink("reevaluate failed"))
.CreateLogger();
var clock = new RedirtyingThrowingClock(up, "Temp", Handovers);
using var eng = new ScriptedAlarmEngine(
up, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger, clock.Now);
await eng.LoadAsync([
Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;"""),
], TestContext.Current.CancellationToken);
// Arm AFTER load: LoadAsync's startup-recovery pass reads the clock too, and it must not
// be the pass that throws.
clock.Arm();
// One push starts the chain; every failing read re-dirties and throws, so the chain
// self-sustains for exactly Handovers handovers and then stops.
up.Push("Temp", 150);
await WaitForAsync(() => clock.CallCount >= Handovers + 1, timeoutMs: 60000);
clock.CallCount.ShouldBe(Handovers + 1,
"every handover must produce exactly one further evaluation pass — no stall, no duplication");
(clock.MaxDepth - clock.FirstDepth).ShouldBeLessThan(200,
$"a queued handover starts on a fresh stack, so depth must stay flat across {Handovers} " +
$"handovers (first={clock.FirstDepth}, max={clock.MaxDepth})");
// Liveness: the latch was released at the end of the chain, so the engine still works.
clock.Disarm();
up.Push("Temp", 170);
await WaitForAsync(() => eng.GetState("A")!.Active == AlarmActiveState.Active, timeoutMs: 5000);
}
// -------------------------------------------------------------------------
// TrackBackgroundTask: fire-and-forget work must be REGISTERED BEFORE IT STARTS.
//
// The old signature took a Task, so the caller evaluated the argument — starting
// the work — and only then called in to register it. Dispose snapshots the
// in-flight set under its lock and synchronously awaits the snapshot, so a Dispose
// landing in that start→Add window drained a set that did not yet contain a
// genuinely running task, and returned while its SaveAsync was still in flight.
// The factory form removes the window rather than shrinking it.
// -------------------------------------------------------------------------
/// <summary>Verifies the background task is registered for the Dispose drain BEFORE the factory
/// that starts it is invoked, so no snapshot can ever miss a task that runs.</summary>
[Fact]
public void Background_work_is_registered_before_the_factory_starts_it()
{
var up = new FakeUpstream();
using var eng = Build(up, out _);
var countSeenInsideFactory = -1;
var gate = new TaskCompletionSource();
eng.TrackBackgroundTaskForTest(() =>
{
// Sampled from inside the factory's own synchronous body — i.e. before the
// work's Task even exists, which is precisely the window the old shape left open.
countSeenInsideFactory = eng.InFlightCountForTest;
return gate.Task;
});
// Release BEFORE asserting: on the falsification run (registration moved after the
// factory) the assertion throws, and `using` would then Dispose into a drain that
// waits on a gate this method never reached — a hang instead of a failure.
gate.SetResult();
countSeenInsideFactory.ShouldBe(1,
"registration must happen before the factory starts the work, or Dispose can drain " +
"past a task that is genuinely in flight");
}
/// <summary>Verifies that after Dispose the factory form declines: no factory is invoked and
/// nothing is registered, so a disposed engine can never acquire new background work.</summary>
[Fact]
public void Disposed_engine_declines_to_start_new_background_work()
{
var up = new FakeUpstream();
var eng = Build(up, out _);
eng.Dispose();
var invoked = false;
eng.TrackBackgroundTaskForTest(() => { invoked = true; return Task.CompletedTask; });
invoked.ShouldBeFalse("a disposed engine must not start new background work");
eng.InFlightCountForTest.ShouldBe(0, "and must not register anything for a drain that already ran");
}
/// <summary>Verifies Dispose blocks on work started through the factory form, and that the
/// registration is released once that work completes.</summary>
[Fact]
public async Task Dispose_awaits_background_work_started_through_the_factory()
{
var up = new FakeUpstream();
var eng = Build(up, out _);
var gate = new TaskCompletionSource();
eng.TrackBackgroundTaskForTest(() => gate.Task);
eng.InFlightCountForTest.ShouldBe(1);
var disposeTask = Task.Run(() => eng.Dispose());
var raced = await Task.WhenAny(disposeTask, Task.Delay(300));
raced.ShouldNotBe(disposeTask, "Dispose must not return while registered background work is still running");
gate.SetResult();
await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
eng.InFlightCountForTest.ShouldBe(0, "completed work must unregister itself");
}
/// <summary>Verifies work whose task is already complete when the factory returns still
/// unregisters cleanly and leaves Dispose with nothing to wait on.</summary>
[Fact]
public void Already_completed_background_work_unregisters_and_leaves_Dispose_free()
{
var up = new FakeUpstream();
var eng = Build(up, out _);
eng.TrackBackgroundTaskForTest(() => Task.CompletedTask);
eng.InFlightCountForTest.ShouldBe(0,
"the self-removal continuation runs inline on an already-completed task");
Should.NotThrow(() => eng.Dispose());
}
/// <summary>Verifies a factory that throws synchronously surfaces the throw to its caller (the
/// behaviour the old evaluate-at-the-call-site shape had) without wedging Dispose on a
/// placeholder that nothing will ever complete.</summary>
[Fact]
public async Task A_factory_that_throws_synchronously_rethrows_and_does_not_wedge_Dispose()
{
var up = new FakeUpstream();
var eng = Build(up, out _);
Should.Throw<InvalidOperationException>(() => eng.TrackBackgroundTaskForTest(
() => throw new InvalidOperationException("factory failed (test)")));
eng.InFlightCountForTest.ShouldBe(0, "work that never started must not be left registered");
await Task.Run(() => eng.Dispose()).WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies a faulted background task is still drained by Dispose and its failure is
/// swallowed there (logged at debug), matching the pre-existing shutdown contract.</summary>
[Fact]
public async Task A_faulted_background_task_is_drained_by_Dispose_without_throwing()
{
var up = new FakeUpstream();
var eng = Build(up, out _);
var gate = new TaskCompletionSource();
eng.TrackBackgroundTaskForTest(() => gate.Task);
// Fault it while Dispose is actually blocked on the drain, so the WhenAll observes
// the failure rather than finding an already-unregistered task.
var disposeTask = Task.Run(() => Should.NotThrow(() => eng.Dispose()));
await Task.Delay(200);
gate.SetException(new InvalidOperationException("background failed (test)"));
await disposeTask.WaitAsync(TimeSpan.FromSeconds(5));
eng.InFlightCountForTest.ShouldBe(0);
}
/// <summary>
/// A clock that, once armed, re-dirties an alarm by pushing to the upstream and then throws —
/// driving a deterministic chain of drain-latch handovers whose every step is synchronous —
/// while sampling the native stack depth of each call.
/// </summary>
private sealed class RedirtyingThrowingClock
{
private readonly FakeUpstream _up;
private readonly string _path;
private readonly int _redirtyLimit;
private readonly object _gate = new();
private volatile bool _armed;
private int _calls;
private int _firstDepth;
private int _maxDepth;
/// <summary>Initializes the clock with the upstream to re-dirty and the handover count to drive.</summary>
/// <param name="up">Upstream fake the failing call re-pushes to.</param>
/// <param name="path">Tag path to re-push, which must be one the alarm reads.</param>
/// <param name="redirtyLimit">Number of calls that re-dirty before the chain is allowed to end.</param>
public RedirtyingThrowingClock(FakeUpstream up, string path, int redirtyLimit)
{ _up = up; _path = path; _redirtyLimit = redirtyLimit; }
/// <summary>Gets the number of clock reads taken while armed.</summary>
public int CallCount => Volatile.Read(ref _calls);
/// <summary>Gets the stack depth sampled on the first armed read.</summary>
public int FirstDepth => Volatile.Read(ref _firstDepth);
/// <summary>Gets the deepest stack sampled across all armed reads.</summary>
public int MaxDepth => Volatile.Read(ref _maxDepth);
/// <summary>Starts failing (and re-dirtying) on every read.</summary>
public void Arm() => _armed = true;
/// <summary>Stops failing, so the clock behaves normally again.</summary>
public void Disarm() => _armed = false;
/// <summary>Samples the stack depth, re-dirties the alarm, and throws — or returns the time when disarmed.</summary>
public DateTime Now()
{
if (!_armed) return DateTime.UtcNow;
var n = Interlocked.Increment(ref _calls);
var depth = new System.Diagnostics.StackTrace(false).FrameCount;
lock (_gate)
{
if (n == 1) _firstDepth = depth;
if (depth > _maxDepth) _maxDepth = depth;
}
// Re-dirty from inside the failing pass: the latch is held by the drainer that
// called us, so this only grows the dirty set — which is exactly the condition
// that makes the unwinding drainer hand the latch over instead of releasing it.
if (n <= _redirtyLimit) _up.Push(_path, 150);
throw new InvalidOperationException("clock unavailable (test)");
}
}
/// <summary>
/// A Serilog sink that throws when a marker event is emitted. Registered through
/// <c>AuditTo</c> so the failure propagates to the caller instead of being swallowed by
/// Serilog's normal best-effort emit — the mechanism the drain-latch test uses to make an
/// exception escape a method that otherwise catches everything.
/// </summary>
private sealed class ThrowingSink : Serilog.Core.ILogEventSink
{
private readonly string _marker;
/// <summary>Initializes the sink with the marker substring that triggers the throw.</summary>
/// <param name="marker">Substring the message template must contain to throw.</param>
public ThrowingSink(string marker) => _marker = marker;
/// <summary>Throws if the event's message template carries the marker; otherwise ignores it.</summary>
/// <param name="logEvent">The Serilog event being emitted.</param>
public void Emit(Serilog.Events.LogEvent logEvent)
{
if (logEvent.MessageTemplate.Text.Contains(_marker, StringComparison.Ordinal))
throw new InvalidOperationException($"audit sink refused '{_marker}' (test)");
}
}
/// <summary>
/// A Serilog sink that counts the events whose message template contains a marker string.
/// Used to count predicate EVALUATIONS (the predicate emits the marker through
/// <c>ctx.Logger</c>) — the store's save count cannot serve as that probe, because a
/// redundant re-evaluation produces no transition and therefore no save.
/// </summary>
private sealed class CountingSink : Serilog.Core.ILogEventSink
{
private readonly string _marker;
private int _count;
/// <summary>Initializes the sink with the marker substring it counts.</summary>
/// <param name="marker">Substring the message template must contain to be counted.</param>
public CountingSink(string marker) => _marker = marker;
/// <summary>Gets the number of matching events emitted since the last <see cref="Reset"/>.</summary>
public int Count => Volatile.Read(ref _count);
/// <summary>Resets the counter to zero.</summary>
public void Reset() => Interlocked.Exchange(ref _count, 0);
/// <summary>Counts the event if its message template carries the marker.</summary>
/// <param name="logEvent">The Serilog event being emitted.</param>
public void Emit(Serilog.Events.LogEvent logEvent)
{
if (logEvent.MessageTemplate.Text.Contains(_marker, StringComparison.Ordinal))
Interlocked.Increment(ref _count);
}
}
/// <summary>
/// Store decorator that counts SaveAsync calls and can hold the next save open until the
/// test releases it — the two capabilities the coalescing tests need together (block one
/// evaluation inside <c>_evalGate</c>, then count what the released drain actually did).
/// </summary>
private sealed class CountingAlarmStateStore : IAlarmStateStore
{
private readonly InMemoryAlarmStateStore _inner = new();
private int _saveCount;
/// <summary>Gets the number of SaveAsync calls since the last <see cref="ResetCount"/>.</summary>
public int SaveCount => Volatile.Read(ref _saveCount);
/// <summary>Resets the save counter to zero.</summary>
public void ResetCount() => Interlocked.Exchange(ref _saveCount, 0);
/// <summary>Gets or sets a TaskCompletionSource that, when set, blocks the next SaveAsync until signalled.</summary>
public TaskCompletionSource? BlockNextSave { get; set; }
/// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
public bool SaveInProgress { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether the next SaveAsync throws instead of persisting.
/// Self-clearing, so one failed save can be followed by a healthy one.
/// </summary>
public bool ThrowOnNextSave { get; set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Counts the save, optionally throwing or blocking on the BlockNextSave gate, then delegates.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
Interlocked.Increment(ref _saveCount);
if (ThrowOnNextSave)
{
ThrowOnNextSave = false;
throw new InvalidOperationException("store unavailable (test)");
}
var gate = BlockNextSave;
if (gate is not null)
{
BlockNextSave = null;
SaveInProgress = true;
try { await gate.Task.WaitAsync(ct).ConfigureAwait(false); }
finally { SaveInProgress = false; }
}
await _inner.SaveAsync(state, ct).ConfigureAwait(false);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
}