using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; /// /// #487 — : the read that lets a caller restore an /// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next /// transition (which for a still-active alarm with static dependencies may never come). /// /// /// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted /// state is already Active and whose predicate is still true produces , /// so the transition stream cannot carry its state. These tests pin both halves — the projection does /// report the state, and reading it does not fire an emission. /// [Trait("Category", "Unit")] public sealed class ScriptedAlarmProjectionTests { private static ScriptedAlarmDefinition Alarm( string id = "HighTemp", string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""", string msg = "Temp is {Temp}", AlarmSeverity sev = AlarmSeverity.High) => new(AlarmId: id, EquipmentPath: "Plant/Line1/Reactor", AlarmName: id, Kind: AlarmKind.AlarmCondition, Severity: sev, MessageTemplate: msg, PredicateScriptSource: predicate); /// An already-Active persisted state, as a store would hold it across a restart/redeploy. private static AlarmConditionState PersistedActive( string alarmId = "HighTemp", AlarmAckedState acked = AlarmAckedState.Unacknowledged, DateTime? lastTransitionUtc = null) => new(alarmId, AlarmEnabledState.Enabled, AlarmActiveState.Active, acked, AlarmConfirmedState.Confirmed, ShelvingState.Unshelved, lastTransitionUtc ?? DateTime.UtcNow, LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow, LastClearedUtc: null, LastAckUtc: null, LastAckUser: null, LastAckComment: null, LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null, Comments: []); private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store) { var logger = new LoggerConfiguration().CreateLogger(); return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger); } /// The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection /// reports it Active — so a caller has a way to restore the node the rebuild reset. [Fact] public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active() { var up = new FakeUpstream(); up.Set("Temp", 150); // predicate still true — no transition to report var store = new InMemoryAlarmStateStore(); await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken); using var eng = Build(up, store); var emissions = new List(); eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken); // The premise. If this ever starts emitting, the bug's shape has changed and the re-assert // in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth. lock (emissions) { emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload"); } var projection = eng.GetProjections().ShouldHaveSingleItem(); projection.AlarmId.ShouldBe("HighTemp"); projection.Condition.Active.ShouldBe(AlarmActiveState.Active); projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged); } /// The projection carries the definition's severity and the message template resolved against /// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote. [Fact] public async Task Projection_carries_definition_severity_and_a_resolved_message() { var up = new FakeUpstream(); up.Set("Temp", 150); var store = new InMemoryAlarmStateStore(); await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken); using var eng = Build(up, store); await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken); var projection = eng.GetProjections().ShouldHaveSingleItem(); projection.Severity.ShouldBe(AlarmSeverity.Critical); projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache"); } /// Reading projections is a pure read — it must never raise , /// or every deploy would append a duplicate historian / alerts row. [Fact] public async Task Reading_projections_never_fires_an_emission() { var up = new FakeUpstream(); up.Set("Temp", 150); var store = new InMemoryAlarmStateStore(); await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken); using var eng = Build(up, store); await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken); var emissions = 0; eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions); for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty(); Volatile.Read(ref emissions).ShouldBe(0); } /// An alarm that is genuinely normal projects the no-event position, so a caller can tell /// "nothing to restore" from "restore this". [Fact] public async Task A_normal_alarm_projects_the_no_event_position() { var up = new FakeUpstream(); up.Set("Temp", 50); // predicate false using var eng = Build(up, new InMemoryAlarmStateStore()); await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken); var projection = eng.GetProjections().ShouldHaveSingleItem(); projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive); projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged); projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled); projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); } /// The worst-input quality rides the projection, so restoring a node whose script inputs are /// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path). [Fact] public async Task Projection_carries_the_last_observed_worst_input_quality() { var up = new FakeUpstream(); up.Set("Temp", 150); var store = new InMemoryAlarmStateStore(); await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken); using var eng = Build(up, store); await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken); eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good"); up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated // The push re-evaluates on a background worker; poll for the tracked bucket to move. var deadline = DateTime.UtcNow.AddSeconds(10); while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline) { await Task.Delay(20, TestContext.Current.CancellationToken); } (eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality"); } /// Before the first load there is nothing to project — the read is safe, not a throw. [Fact] public void Projections_are_empty_before_the_first_load() { using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore()); eng.GetProjections().ShouldBeEmpty(); } }