harden(historian): nullable HistorizeToAveva (missing→historize) for rolling-restart-safe deserialize + middle-link test

This commit is contained in:
Joseph Doherty
2026-06-11 13:00:57 -04:00
parent c20d228384
commit 61b230d79a
4 changed files with 65 additions and 11 deletions
@@ -178,13 +178,15 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
}
/// <summary>Builds an <see cref="AlarmTransitionEvent"/> (the shape published on the <c>alerts</c>
/// DPS topic) for the translate tests, with overridable severity / type / comment / kind.</summary>
/// DPS topic) for the translate tests, with overridable severity / type / comment / kind.
/// <paramref name="historizeToAveva"/> is <c>bool?</c> so tests can pass <c>null</c> to simulate the
/// rolling-restart / cross-version case (missing field → CLR default null).</summary>
private static AlarmTransitionEvent SampleTransition(
int severity = 750,
string alarmTypeName = "LimitAlarm",
string? comment = "note",
string transitionKind = "Activated",
bool historizeToAveva = true) => new(
bool? historizeToAveva = true) => new(
AlarmId: "alm-9",
EquipmentPath: "Area/Line/Equip",
AlarmName: "HiHi",
@@ -270,6 +272,23 @@ public sealed class HistorianAdapterActorTests : RuntimeActorTestBase
sink.EnqueueCount.ShouldBe(0);
}
/// <summary>Rolling-restart default-on (T8c): when <c>HistorizeToAveva</c> is <c>null</c> — the shape
/// a cross-version / rolling-restart deserialize produces (old-format message missing the field maps to
/// the CLR default <c>null</c> for <c>bool?</c>) — a Primary node MUST historize. <c>null</c> is the
/// safe default-on posture: no audit row is dropped at a handover, matching the <c>AlarmTypeName</c>
/// null-coalesce precedent in the same <c>HistorianAdapterActor.Translate</c>.</summary>
[Fact]
public void Primary_historizes_when_flag_is_null()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink, LocalNode));
TellRedundancyRole(actor, RedundancyRole.Primary);
actor.Tell(SampleTransition(historizeToAveva: null));
AwaitAssert(() => sink.EnqueueCount.ShouldBe(1), Settle);
}
/// <summary>Severity buckets (T9): the OPC UA 11000 numeric severity on the transition maps onto
/// the coarse <see cref="AlarmSeverity"/> at the same ceilings <c>ScriptedAlarmHostActor.SeverityToInt</c>
/// emits (Low≤250, Medium≤500, High≤750, Critical otherwise). Driven end-to-end through the enqueue.</summary>
@@ -576,6 +576,42 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>HistorizeToAveva flag threading (middle-link): the host MUST carry the plan's
/// <c>HistorizeToAveva</c> flag onto the <see cref="AlarmTransitionEvent"/> it publishes to the
/// <c>alerts</c> topic. Verifies both the <c>true</c> (default <see cref="Plan"/>) and the
/// <c>false</c> (<see cref="BadPlan"/> carries false on the fixture) cases so any regression in
/// <c>ScriptedAlarmHostActor.OnEngineEmission</c>'s flag threading is caught here before
/// <c>HistorianAdapterActor</c>'s opt-out gate becomes the first line of defence.</summary>
[Fact]
public void HistorizeToAveva_flag_is_threaded_onto_published_AlarmTransitionEvent()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
// Build two plans: one with HistorizeToAveva=true (Plan default), one with false.
// Use distinct ids + dep refs so both load cleanly side-by-side.
var planTrue = Plan(id: "alm-hist-true", depRef: "H.T", severity: 800); // HistorizeToAveva: true
var planFalse = Plan(id: "alm-hist-false", depRef: "H.F", severity: 800) with { HistorizeToAveva = false };
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { planTrue, planFalse }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
// Activate the true-flag alarm.
host.Tell(new VirtualTagActor.DependencyValueChanged("H.T", 99, DateTime.UtcNow));
var evtTrue = alerts.FishForMessage<AlarmTransitionEvent>(
e => e.AlarmId == "alm-hist-true" && e.TransitionKind == "Activated", Timeout);
evtTrue.HistorizeToAveva.ShouldBe(true);
// Activate the false-flag alarm.
host.Tell(new VirtualTagActor.DependencyValueChanged("H.F", 99, DateTime.UtcNow));
var evtFalse = alerts.FishForMessage<AlarmTransitionEvent>(
e => e.AlarmId == "alm-hist-false" && e.TransitionKind == "Activated", Timeout);
evtFalse.HistorizeToAveva.ShouldBe(false);
}
/// <summary>Absent-node default-emit (A1): a <see cref="RedundancyStateChanged"/> snapshot that
/// contains ONLY other nodes (the host's own <see cref="LocalNode"/> is absent) must leave the
/// cached local role unchanged (null/unknown) — the host therefore defaults to emit, publishing