using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Akka.TestKit; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms; /// /// Verifies loads the enabled subset of /// s into its , registers mux /// interest for their dependency refs after the load completes, feeds live /// values into the engine, and bridges the /// engine's emissions to both an and an /// on the cluster alerts topic. /// public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8); /// Plan whose predicate compares the single tag "M.T" against 90 — enabled by default. private static EquipmentScriptedAlarmPlan Plan( string id = "alm-1", string equipmentId = "Plant/Line1/M", string name = "HighTemp", string depRef = "M.T", int threshold = 90, bool enabled = true, int severity = 800) => new( ScriptedAlarmId: id, EquipmentId: equipmentId, Name: name, AlarmType: "AlarmCondition", Severity: severity, MessageTemplate: "condition", PredicateScriptId: $"{id}-script", PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > {threshold};", DependencyRefs: new[] { depRef }, HistorizeToAveva: true, Retain: true, Enabled: enabled); /// Plan whose predicate references an unknown identifier so it fails to compile — applying it /// faults the engine's LoadAsync. Used to prove a faulted load doesn't crash the host. private static EquipmentScriptedAlarmPlan BadPlan(string id = "bad-1") => new( ScriptedAlarmId: id, EquipmentId: "Plant/Line1/M", Name: "Broken", AlarmType: "AlarmCondition", Severity: 500, MessageTemplate: "broken", PredicateScriptId: $"{id}-script", PredicateSource: "return unknownIdentifier;", // uncompilable → LoadAsync throws DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: false, Enabled: true); private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream) { var logger = new LoggerConfiguration().CreateLogger(); return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger); } private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn( TestProbe publish, TestProbe mux) { var upstream = new DependencyMuxTagUpstreamSource(); var engine = BuildEngine(upstream); var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine)); return (host, upstream); } /// Subscribe to the alerts DPS topic and wait for the ack. /// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. private void SubscribeToAlerts(TestProbe probe) { DistributedPubSub.Get(Sys).Mediator.Tell( new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref); probe.ExpectMsg(Timeout); } /// Load + interest: applying one enabled alarm registers mux interest for its dep ref /// AFTER the engine load completes; a disabled alarm in the same apply contributes no dep ref. [Fact] public void Apply_loads_enabled_alarm_and_registers_interest() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var (host, _) = Spawn(publish, mux); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T"), Plan(id: "alm-2", depRef: "M.X", enabled: false), // disabled — not loaded, dep absent })); var reg = mux.ExpectMsg(Timeout); reg.TagRefs.ShouldContain("M.T"); reg.TagRefs.ShouldNotContain("M.X"); } /// Activation path: with the alarm loaded, pushing a value above the threshold drives an /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an /// AlarmTransitionEvent("Activated") on the alerts topic. [Fact] public void Dependency_change_above_threshold_activates_alarm() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (host, _) = Spawn(publish, mux); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) })); mux.ExpectMsg(Timeout); // load completed var now = DateTime.UtcNow; host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, now)); var state = publish.ExpectMsg(Timeout); state.AlarmNodeId.ShouldBe("alm-1"); state.Active.ShouldBeTrue(); var evt = alerts.ExpectMsg(Timeout); evt.AlarmId.ShouldBe("alm-1"); evt.TransitionKind.ShouldBe("Activated"); evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000 evt.User.ShouldBe("system"); } /// Clear path: after activating, pushing a value below the threshold drives Active→Inactive /// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared"). [Fact] public void Dependency_change_below_threshold_clears_alarm() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var alerts = CreateTestProbe(); SubscribeToAlerts(alerts); var (host, _) = Spawn(publish, mux); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() })); mux.ExpectMsg(Timeout); // Activate first. host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); publish.FishForMessage(m => m.Active, Timeout); alerts.FishForMessage(e => e.TransitionKind == "Activated", Timeout); // Now clear. host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow)); var cleared = publish.FishForMessage(m => !m.Active, Timeout); cleared.AlarmNodeId.ShouldBe("alm-1"); var evt = alerts.FishForMessage(e => e.TransitionKind == "Cleared", Timeout); evt.AlarmId.ShouldBe("alm-1"); } /// Re-apply reloads: a second ApplyScriptedAlarms with a different alarm set loads the new /// alarm — a fresh RegisterInterest reflecting the new dependency refs lands on the mux. [Fact] public void Reapply_reloads_with_new_dependency_refs() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var (host, _) = Spawn(publish, mux); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") })); var first = mux.ExpectMsg(Timeout); first.TagRefs.ShouldContain("M.T"); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-9", depRef: "M.Y") })); var second = mux.ExpectMsg(Timeout); second.TagRefs.ShouldContain("M.Y"); second.TagRefs.ShouldNotContain("M.T"); } /// Faulted load resilience: applying an alarm whose predicate doesn't compile faults the /// engine's LoadAsync. The host logs a Warning (Status.Failure handler) and stays alive — it must /// still process a subsequent valid apply, registering interest for that apply's dep refs. [Fact] public void Faulted_load_does_not_crash_host() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var (host, _) = Spawn(publish, mux); // Apply a plan that fails to compile — LoadAsync faults, the host swallows it as a Warning and // does NOT register interest (no dep refs to register, and the load never completed). host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { BadPlan() })); mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // Prove the actor is still responsive: a later valid apply loads + registers interest as normal. host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-ok", depRef: "M.T") })); var reg = mux.ExpectMsg(Timeout); reg.TagRefs.ShouldContain("M.T"); } /// Stale-load guard (fix A): two back-to-back applies with different dep-ref sets must end /// with the mux holding the SECOND (latest-generation) set — a stale earlier completion must never /// re-introduce the first set's refs. /// /// Limitation: forcing the two LoadAsync continuations to complete out of order is not /// deterministic via real async timing (both loads are short + run on the thread pool). This test /// therefore validates the guard's observable contract rather than the race itself: it fishes for /// the RegisterInterest carrying the second apply's refs, then asserts no LATER RegisterInterest /// re-introduces the first apply's refs. With the generation guard in place the latest apply always /// wins; without it, an out-of-order stale completion could land a "M.A"-bearing RegisterInterest /// after the "M.B" one — which this assertion would catch. [Fact] public void Stale_load_does_not_register_superseded_dep_refs() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var (host, _) = Spawn(publish, mux); // Fire two applies in quick succession with disjoint dep refs; the second supersedes the first. host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-a", depRef: "M.A") })); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-b", depRef: "M.B") })); // The latest generation must win: fish for the RegisterInterest reflecting the second apply. mux.FishForMessage(r => r.TagRefs.Contains("M.B"), Timeout); // No LATER RegisterInterest may re-introduce the first (superseded) apply's "M.A" ref. mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } }