Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
T
Joseph Doherty 9a87d7a328 Resolve all 33 medium-severity architecture review findings
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
2026-09-09 22:05:30 -04:00

674 lines
34 KiB
C#
Raw 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 Stop_ReleasesAlarmSubscriptionWithOwnerIdentity()
{
var dcl = CreateTestProbe();
var actor = Spawn(CreateTestProbe().Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
Sys.Stop(actor);
var request = dcl.ExpectMsg<UnsubscribeAlarmsRequest>();
Assert.Equal(Source().SourceReference, request.SourceReference);
Assert.Equal(actor, dcl.LastSender);
}
[Fact]
public void DuplicateSnapshotEnd_DoesNotClearCommittedAlarm()
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var epoch = Guid.NewGuid();
NativeAlarmTransition Framed(AlarmTransitionKind kind)
{
var transition = Transition("T01.Hi", kind,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800));
var json = JsonSerializer.SerializeToNode(transition)!;
json["SnapshotId"] = JsonSerializer.SerializeToNode(epoch);
json["SnapshotSourceReference"] = Source().SourceReference;
return json.Deserialize<NativeAlarmTransition>()!;
}
// Append-only enum value for the new start marker; before the fix it is not handled.
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Framed((AlarmTransitionKind)7)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Framed(AlarmTransitionKind.Snapshot)));
var end = new NativeAlarmTransitionUpdate("Opc", Framed(AlarmTransitionKind.SnapshotComplete));
actor.Tell(end);
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
// Before the fix there is also an unwanted Active event for the unrecognized start.
// Fish out all initial active events before applying the duplicate marker.
actor.Tell(new SubscribeAlarmsResponse("barrier", "inst", true, null, DateTimeOffset.UtcNow));
instance.ReceiveWhile(TimeSpan.FromMilliseconds(100), msg => msg);
actor.Tell(end);
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[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);
}
// ── MES alarm-status API §6.4: AckTime through the mirror ──────────────
[Fact]
public void Emit_CarriesTheAdaptersAckTimeVerbatim()
{
// The DCL adapter already decided whether an ack instant applies; the mirror must
// neither invent one for an unacked raise nor drop the one on an ack transition.
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var actor = Spawn(instance.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var raisedAt = DateTimeOffset.UtcNow;
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
"T01.Hi", AlarmTransitionKind.Raise,
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), raisedAt)));
Assert.Null(instance.ExpectMsg<AlarmStateChanged>().AckTime);
var ackedAt = raisedAt.AddMinutes(2);
actor.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"T01.Hi", "T01", "AnalogLimit.Hi", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800),
"Process", "hi", "hi", "op1", "ack", null, ackedAt, "92", "90", AckTime: ackedAt)));
var acked = instance.ExpectMsg<AlarmStateChanged>();
Assert.Equal(ackedAt, acked.AckTime);
Assert.True(acked.Condition.Acknowledged);
}
[Fact]
public async Task Rehydration_RestoresAckTime_SoItSurvivesFailover()
{
// A failover must not make an acknowledged alarm look freshly unacknowledged: the
// ack instant rides metadata_json (see NativeAlarmMetadata) and comes back on the
// rehydration emit of a fresh actor over the SAME site database.
var instance1 = CreateTestProbe();
var dcl1 = CreateTestProbe();
var actor1 = SpawnWithFlush(instance1.Ref, dcl1.Ref, TimeSpan.FromMilliseconds(100));
dcl1.ExpectMsg<SubscribeAlarmsRequest>();
var ackedAt = new DateTimeOffset(2026, 8, 1, 12, 0, 0, TimeSpan.Zero);
actor1.Tell(new NativeAlarmTransitionUpdate("Opc", new NativeAlarmTransition(
"ref-ack", "T01", "HighLevelAlarm", AlarmTransitionKind.Acknowledge,
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800),
"Process", "desc", "Tank overflow", "op1", "ack", null, DateTimeOffset.UtcNow, "92", "90",
AckTime: ackedAt)));
instance1.ExpectMsg<AlarmStateChanged>(m => m.AckTime == ackedAt);
await AwaitAssertAsync(async () =>
{
var rows = await _storage.GetNativeAlarmsAsync("inst", Source().CanonicalName);
Assert.Contains(rows, r => r.MetadataJson != null && r.MetadataJson.Contains("AckTime"));
}, TimeSpan.FromSeconds(3));
var instance2 = CreateTestProbe();
var dcl2 = CreateTestProbe();
SpawnWithFlush(instance2.Ref, dcl2.Ref, TimeSpan.FromMilliseconds(100));
var emitted = instance2.FishForMessage<AlarmStateChanged>(
m => m.SourceReference == "ref-ack", TimeSpan.FromSeconds(5));
Assert.Equal(ackedAt, emitted.AckTime);
}
[Fact]
public async Task Rehydration_OfAPreAckTimeRow_LeavesAckTimeNull()
{
// Rows written before this change carry metadata_json without an AckTime property.
// System.Text.Json leaves the missing property at null — "ack time unknown" — rather
// than failing the rehydration and discarding the condition.
var legacyMetadata = JsonSerializer.Serialize(new
{
AlarmTypeName = "HighLevelAlarm",
Category = "Process",
Message = "Tank overflow",
CurrentValue = "92",
LimitValue = "90"
});
await _storage.UpsertNativeAlarmAsync(
"inst", Source().CanonicalName, "ref-legacy",
JsonSerializer.Serialize(
new AlarmConditionState(true, true, null, AlarmShelveState.Unshelved, false, 800)),
DateTimeOffset.UtcNow, legacyMetadata);
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
Spawn(instance.Ref, dcl.Ref);
var emitted = instance.FishForMessage<AlarmStateChanged>(
m => m.SourceReference == "ref-legacy", TimeSpan.FromSeconds(5));
Assert.Equal("HighLevelAlarm", emitted.AlarmTypeName); // metadata still restored…
Assert.Null(emitted.AckTime); // …with no ack time invented
}
[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));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SR12_LateRehydrationCannotResurrectResolvedOrSnapshotRemovedAlarm(bool snapshot)
{
var instance = CreateTestProbe();
var dcl = CreateTestProbe();
var read = new TaskCompletionSource<IReadOnlyList<NativeAlarmRow>>(TaskCreationOptions.RunContinuationsAsynchronously);
var actor = ActorOf(Props.Create(() => new NativeAlarmActor(Source(), "inst", instance.Ref, dcl.Ref, _storage, _options,
NullLogger.Instance, AlarmKind.NativeOpcUa, null, null, read.Task)));
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
await _storage.UpsertNativeAlarmAsync("saved", "Pressure", "T01.Hi", JsonSerializer.Serialize(condition), DateTimeOffset.UtcNow);
var rows = await _storage.GetNativeAlarmsAsync("saved", "Pressure");
if (snapshot)
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotComplete, condition)));
else
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("T01.Hi", AlarmTransitionKind.Clear, condition with { Active = false, Acknowledged = true })));
read.SetResult(rows);
var messages = instance.ReceiveWhile<object>(o => o, TimeSpan.FromMilliseconds(300));
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), e => e.State == AlarmState.Active);
}
[Fact]
public void SR3_StaleSuccessCannotCancelCurrentSubscribeRetry()
{
var dcl = CreateTestProbe();
var actor = Spawn(CreateTestProbe().Ref, dcl.Ref, new SiteRuntimeOptions { NativeAlarmRetryIntervalMs = 100 });
var first = dcl.ExpectMsg<SubscribeAlarmsRequest>();
var second = dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(2));
Assert.NotEqual(first.CorrelationId, second.CorrelationId);
actor.Tell(new SubscribeAlarmsResponse(first.CorrelationId, "inst", true, null, DateTimeOffset.UtcNow));
var third = dcl.ExpectMsg<SubscribeAlarmsRequest>(TimeSpan.FromSeconds(3));
actor.Tell(new SubscribeAlarmsResponse(third.CorrelationId, "inst", true, null, DateTimeOffset.UtcNow));
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void DC5_ConnectionWideRestartDiscardsInterruptedMxSnapshot()
{
var parent = CreateTestProbe(); var dcl = CreateTestProbe();
var actor = Spawn(parent.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("Old.Hi", AlarmTransitionKind.Snapshot, condition)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotStart, condition)));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition("", AlarmTransitionKind.SnapshotComplete, condition)));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public void DC5_LiveClearDuringSnapshotCannotBeResurrectedBySnapshotEnd()
{
var parent = CreateTestProbe(); var dcl = CreateTestProbe();
var actor = Spawn(parent.Ref, dcl.Ref);
dcl.ExpectMsg<SubscribeAlarmsRequest>();
var condition = new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800);
var snapshot = Transition("A.Hi", AlarmTransitionKind.Snapshot, condition) with { SnapshotId = Guid.NewGuid(), SnapshotSourceReference = Source().SourceReference };
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.SnapshotStart }));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.Clear, Condition = condition with { Active = false, Acknowledged = true }, TransitionTime = snapshot.TransitionTime.AddSeconds(1) }));
actor.Tell(new NativeAlarmTransitionUpdate("Opc", snapshot with { Kind = AlarmTransitionKind.SnapshotComplete }));
var messages = parent.ReceiveWhile<object>(o => o, TimeSpan.FromMilliseconds(300));
Assert.DoesNotContain(messages.OfType<AlarmStateChanged>(), m => m.State == AlarmState.Active);
}
}