Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
Joseph Doherty f2efeb37b7 refactor(sf,site): both stores take ILocalDb instead of a connection string
Tasks 5 and 6 of the Phase 2 plan, committed together because their test
fallout is entangled — several fixtures construct both stores.

StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections
come from ILocalDb.CreateConnection(), which hands out an already-open,
pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers
call; a raw connection would lack the UDF and every write to a replicated table
would fail closed. Deleted with the connection strings: S&F's
EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site
service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now.

DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the
Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and
StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source
locations in Tasks 8/9.

Two things the plan did not anticipate, both worth reading:

1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed
   directory creation simply moved to LocalDb along with file ownership. It did
   not: the LocalDb library never creates the parent directory, and
   SqliteLocalDb opens the file eagerly in its constructor — so a missing
   directory is a hard boot failure ("SQLite Error 14: unable to open database
   file"), not a degraded start. The default site config points at the RELATIVE
   path ./data/site-localdb.db, so any site node without a pre-existing data/
   directory fails to boot. The docker rig escapes only because its volume mount
   happens to create /app/data — a coincidence that would have hidden this until
   a bare-metal or fresh deployment. This has been latent since Phase 1 made
   LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here
   would have widened it. Re-established the guarantee at the layer that now
   owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and
   pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two
   tests written against the wrong assumption failed with exactly this
   SQLite Error 14 before the fix existed.

2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one
   project; the constructor change actually reaches 40 files across 7 test
   projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no
   equivalent for, so every one had to move to a real temp file. Rather than
   copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared
   tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the
   WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place.

Retargeted rather than deleted, in both directions: the S&F WAL test now asserts
against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the
directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's).
SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment.
DeploymentManagerMediumFindingsTests induced a persistence failure via an
unopenable path, which no longer reaches the assertion since the fixture now
throws first; it induces the same failure shape via an uninitialized store.

Verified: full solution build 0 warnings; SiteRuntime 532, Host 318,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97,
StoreAndForward 153 — 1597 passed, 0 failed.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-20 03:05:45 -04:00

472 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Task 15: NativeAlarmActor mirrors a source's native alarms — subscribe on start,
/// emit enriched AlarmStateChanged, snapshot atomic swap, retention, persistence.
/// </summary>
public class NativeAlarmActorTests : TestKit, IDisposable
{
private readonly TestLocalDb _localDb;
private readonly SiteStorageService _storage;
private readonly SiteRuntimeOptions _options = new();
public NativeAlarmActorTests()
{
// SiteStorageService takes an ILocalDb now; LocalDb has no in-memory mode, so the
// fixture is a real temp file (deleted in Dispose, after the TestKit shutdown).
_localDb = TestLocalDb.CreateTemp("naa");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
}
private static ResolvedNativeAlarmSource Source() => new()
{
CanonicalName = "Pressure",
ConnectionName = "Opc",
SourceReference = "ns=2;s=T01"
};
private static NativeAlarmTransition Transition(
string sourceRef, AlarmTransitionKind kind, AlarmConditionState condition, DateTimeOffset? time = null) =>
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
Spawn(instanceActor, dclManager, _options, serviceProvider);
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, SiteRuntimeOptions options,
IServiceProvider? serviceProvider = null) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
Source(), "inst", instanceActor, dclManager, _storage, options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
[Fact]
public void SubscribeOnStart_SendsRequestForSourceBinding()
{
var dcl = CreateTestProbe();
Spawn(CreateTestProbe().Ref, dcl.Ref);
var req = dcl.ExpectMsg<SubscribeAlarmsRequest>();
Assert.Equal("inst", req.InstanceUniqueName);
Assert.Equal("Opc", req.ConnectionName);
Assert.Equal("ns=2;s=T01", req.SourceReference);
}
[Fact]
public void Raise_EmitsEnrichedAlarmStateChanged()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
var emitted = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal(AlarmKind.NativeOpcUa, emitted.Kind);
Assert.Equal("T01.Hi", emitted.SourceReference);
Assert.Equal(AlarmState.Active, emitted.State);
Assert.Equal(800, emitted.Condition.Severity);
}
[Fact]
public void Raise_StampsNativeSourceCanonicalName_FromConfiguredBinding()
{
// DV-1: every emitted condition carries the canonical name of the source
// BINDING it belongs to, so the DebugView can nest live native conditions
// under their configured binding node. It must equal the binding's
// CanonicalName used to construct the actor (Source().CanonicalName).
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
var emitted = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal(Source().CanonicalName, emitted.NativeSourceCanonicalName);
Assert.Equal("Pressure", emitted.NativeSourceCanonicalName);
}
[Fact]
public void SnapshotComplete_WithMissingPriorAlarm_EmitsReturnToNormal()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// A fresh snapshot that no longer contains T01.Hi means it cleared at the source.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
var cleared = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal("T01.Hi", cleared.SourceReference);
Assert.Equal(AlarmState.Normal, cleared.State);
}
[Fact]
public void OlderTransition_IsIgnored()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// An out-of-order (older) transition must not overwrite newer state.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Clear,
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(-30))));
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
// ── PLAN-03 Task 20 (P4): coalesced batched persistence ─────────────────
private IActorRef SpawnWithFlush(IActorRef instanceActor, IActorRef dclManager, TimeSpan flushInterval) =>
ActorOf(Props.Create(() => new NativeAlarmActor(
Source(), "inst", instanceActor, dclManager, _storage, _options,
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, null, flushInterval)));
private static void DeliverLiveTransition(IActorRef actor, string sourceRef, int severity, DateTimeOffset time) =>
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
sourceRef, AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, severity), time)));
[Fact]
public async Task AlarmStorm_CoalescesPersistence_LatestPerSourceRefWins()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = SpawnWithFlush(instance.Ref, dcl.Ref, TimeSpan.FromMilliseconds(100));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// 50 live transitions across 5 source refs (strictly increasing time per ref so none
// is dropped as stale) — the coalesced flush must collapse them to one row per ref.
var baseTime = DateTimeOffset.UtcNow;
for (var i = 0; i < 50; i++)
DeliverLiveTransition(actor, $"ref-{i % 5}", severity: i, baseTime.AddMilliseconds(i));
await Task.Delay(500); // > flush interval
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Equal(5, rows.Count);
// The LAST write for ref-4 carries severity 49 (i = 49, 49 % 5 == 4).
Assert.Contains(rows, r => r.ConditionJson.Contains("49"));
}
// ── PLAN-03 Task 21 (UA4): rehydration restores display metadata ────────
[Fact]
public async Task Rehydration_RestoresDisplayMetadata()
{
var instance1 = CreateTestProbe();
var dcl1 = CreateTestProbe();
var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100));
dcl1.ExpectMsg<SubscribeAlarmsRequest>();
// A live raise carrying full display metadata.
actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"ref-1", "T01", "HighLevelAlarm", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800),
"Process", "desc", "Tank overflow", "", "", null, DateTimeOffset.UtcNow, "92", "90")));
instance1.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Wait for the coalesced flush to land the metadata in SQLite.
await AwaitAssertAsync(async () =>
{
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("HighLevelAlarm"));
}, TimeSpan.FromSeconds(3));
// A fresh actor on the SAME storage rehydrates in PreStart and re-emits the condition
// with the restored metadata instead of empty strings.
var instance2 = CreateTestProbe();
var dcl2 = CreateTestProbe();
SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100));
var emitted = instance2.FishForMessage<AlarmStateChanged>(m => m.SourceReference == "ref-1", TimeSpan.FromSeconds(5));
Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName);
Assert.Equal("Process", emitted.Category);
Assert.Equal("Tank overflow", emitted.Message);
}
// ── M1.5: site event log `alarm` category ──────────────────────────────
[Fact]
public void Raise_EmitsAlarmSiteEvent()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
AwaitAssert(() =>
{
var rows = siteLog.OfType("alarm");
Assert.Single(rows);
var row = rows[0];
Assert.Equal("Error", row.Severity); // severity 800 → Error
Assert.Equal("inst", row.InstanceId);
Assert.Equal("NativeAlarmActor:Pressure", row.Source);
}, TimeSpan.FromSeconds(2));
}
[Fact]
public void Clear_EmitsInfoAlarmSiteEvent()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Clear (inactive but not yet acked → stays mirrored, return-to-normal emit).
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Clear,
new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Normal);
AwaitAssert(() =>
{
var rows = siteLog.OfType("alarm");
Assert.Equal(2, rows.Count); // raise + clear
Assert.Equal("Error", rows[0].Severity);
Assert.Equal("Info", rows[1].Severity); // return-to-normal → Info
}, TimeSpan.FromSeconds(2));
}
[Fact]
public async Task Rehydration_DoesNotEmitSiteEvent()
{
// Pre-populate SQLite with an active condition so the actor rehydrates
// it on PreStart. Rehydration replays last-known state — it is NOT a
// live transition, so it must surface upward (for the DebugView) but
// must NOT re-log an `alarm` operational event.
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
await _storage.UpsertNativeAlarmAsync(
"inst", "Pressure", "T01.Hi",
JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow);
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
// The rehydrated condition is surfaced upward...
var emitted = instance.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(2));
Assert.Equal("T01.Hi", emitted.SourceReference);
Assert.Equal(AlarmState.Active, emitted.State);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// ...but no `alarm` operational event is logged for it.
AwaitAssert(
() => Assert.Empty(siteLog.OfType("alarm")),
TimeSpan.FromSeconds(1));
}
[Fact]
public void SnapshotSwap_ExistingActiveCondition_DoesNotReEmit()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// Live raise — the one and only `alarm` event we expect.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
AwaitAssert(() => Assert.Single(siteLog.OfType("alarm")), TimeSpan.FromSeconds(2));
// A reconnect snapshot that RE-INCLUDES the same still-active condition is
// a re-sync, not a live transition. It must NOT re-log a second `alarm`
// event (regression for the spurious-reconnect-event bug).
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Snapshot,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.SnapshotComplete,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
// The snapshot still surfaces the condition upward (DebugView re-sync)...
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Active);
// ...but the `alarm` event count stays at exactly 1 — no re-emit.
Thread.Sleep(200); // give any spurious fire-and-forget log time to land
Assert.Single(siteLog.OfType("alarm"));
}
[Fact]
public void Acknowledge_EmitsInfoAlarmSiteEventMentioningAcknowledged()
{
var siteLog = new FakeSiteEventLogger();
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Operator acknowledges the still-active condition. The Acknowledge
// branch of LogAlarmEvent logs Info and mentions "acknowledged".
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
instance.ExpectMsg<AlarmStateChanged>();
AwaitAssert(() =>
{
var rows = siteLog.OfType("alarm");
Assert.Equal(2, rows.Count); // raise + acknowledge
var ack = rows[1];
Assert.Equal("Info", ack.Severity);
Assert.Contains("acknowledged", ack.Message, StringComparison.OrdinalIgnoreCase);
}, TimeSpan.FromSeconds(2));
}
// ── SiteRuntime-028: cap eviction emits a return-to-normal for an active drop ──
[Fact]
public void EnforceCap_EvictsActiveOldestCondition_EmitsReturnToNormalAndDropSignal()
{
// SiteRuntime-028: a cap eviction that drops a still-Active condition without a
// return-to-normal leaves the Instance Actor (and central's stream / the
// operator Alarm Summary) showing a phantom Active alarm forever. With cap=1,
// raising a second condition evicts the oldest (still Active) one — which must
// produce a Normal AlarmStateChanged for the evicted SourceReference, plus the
// SiteRuntime-027 NativeAlarmDropped so the parent evicts its stale key.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var options = new SiteRuntimeOptions { MirroredAlarmCapPerSource = 1 };
var actor = Spawn(instance.Ref, dcl.Ref, options);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
// Oldest active condition (will be evicted when the cap is exceeded).
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"OLD.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Active);
// Newer active condition pushes the set to 2 > cap(1) → OLD.Hi is evicted.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"NEW.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0.AddSeconds(5))));
// The new condition is emitted active...
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "NEW.Hi" && m.State == AlarmState.Active);
// ...and the evicted oldest condition must be cleared (return-to-normal), not
// left phantom-active.
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "OLD.Hi" && m.State == AlarmState.Normal);
// ...and the parent is told to evict the stale _latestAlarmEvents key.
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "OLD.Hi");
}
// ── SiteRuntime-027: terminal retention drop signals the parent to evict its key ──
[Fact]
public void RetentionDrop_ResolvedCondition_EmitsReturnToNormalThenDropSignal()
{
// SiteRuntime-027: when a native condition resolves (inactive AND acknowledged)
// it drops out of the mirror. The Instance Actor must be told (NativeAlarmDropped)
// so its _latestAlarmEvents map does not retain a stale (Normal) entry forever —
// otherwise a source that mints a fresh SourceReference per occurrence leaks one
// entry per condition the instance has ever seen.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var t0 = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Resolved: inactive AND acknowledged → return-to-normal emitted, then dropped.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Clear,
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
instance.ExpectMsg<AlarmStateChanged>(m => m.SourceReference == "T01.Hi" && m.State == AlarmState.Normal);
instance.ExpectMsg<NativeAlarmDropped>(m => m.SourceReference == "T01.Hi");
}
void IDisposable.Dispose()
{
// Shut the actor system down FIRST: in-flight alarm actors still hold the
// ILocalDb, and their coalesced flush would hit a disposed database otherwise.
Shutdown();
// Then dispose — the master connection anchors the WAL, so the sidecars cannot
// be removed while it is open.
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
[Fact]
public void LostSubscribeResponse_ResendsSubscribe()
{
// S4: a LOST subscribe response (no reply at all) must be re-sent, not only
// a failure reply. The response-timeout arm fires at 2× the retry interval.
var options = new SiteRuntimeOptions { NativeAlarmRetryIntervalMs = 200 };
var dcl = CreateTestProbe();
Spawn(CreateTestProbe().Ref, dcl.Ref, options);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
// Swallow the response entirely — the actor must still re-subscribe.
dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(5));
}
}