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; /// /// End-to-end engine tests: load, predicate evaluation, change-triggered /// re-evaluation, state persistence, startup recovery, error isolation. /// [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) => new(AlarmId: id, EquipmentPath: "Plant/Line1/Reactor", AlarmName: id, Kind: AlarmKind.AlarmCondition, Severity: sev, MessageTemplate: msg, PredicateScriptSource: predicate); [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); } [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(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"); } [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(); 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); } [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(); 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); } [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(); eng.OnEvent += (_, e) => events.Add(e); up.Push("Temp", 150); await WaitForAsync(() => events.Any()); events[0].Message.ShouldBe("Temp 150C exceeded limit 100C"); } [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(); } [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"); } [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(); 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"); } [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); } [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); } [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"); } [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); } [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); } private static async Task WaitForAsync(Func 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"); } }