using Akka.Actor; using Akka.TestKit; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; 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; /// /// R02:H1 — a supervision restart of used to dispose the /// Props-captured engine and re-create the actor around the disposed instance, so every later /// ApplyScriptedAlarms failed and the node's scripted alarms silently died. With the factory /// Props each incarnation builds its own engine; with the legacy Props a restart only detaches. Both /// shapes must load cleanly after a throw inside a /// handler. Also R02:H3 — an inbound alarm command's engine op is bounded. /// public sealed class ScriptedAlarmHostActorRestartTests : RuntimeActorTestBase { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); private static EquipmentScriptedAlarmPlan Plan(string id = "alm-1", string depRef = "M.T") => new(ScriptedAlarmId: id, EquipmentId: "Plant/Line1/M", Name: "HighTemp", AlarmType: "AlarmCondition", Severity: 800, MessageTemplate: "condition", PredicateScriptId: $"{id}-script", PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > 90;", DependencyRefs: new[] { depRef }, HistorizeToAveva: true, Retain: true, Enabled: true); private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream, IAlarmStateStore? store = null) { var logger = new LoggerConfiguration().CreateLogger(); return new ScriptedAlarmEngine(upstream, store ?? new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger); } private static (DependencyMuxTagUpstreamSource, ScriptedAlarmEngine) Pair() { var up = new DependencyMuxTagUpstreamSource(); return (up, BuildEngine(up)); } /// Apply, crash the handler (a null TagId makes Push throw → restart), apply again /// and prove the second load registers interest and the alarm still activates. private void RestartThenReload(IActorRef host, TestProbe publish, TestProbe mux) { host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() })); mux.ExpectMsg(Timeout); // The crash: a batch whose item has a null TagId makes the upstream Push throw out of the handler // (the documented, deliberately unguarded path) → supervision restarts the actor. host.Tell(new VirtualTagActor.DependencyValueBatch(new[] { new VirtualTagActor.DependencyValueChanged(null!, 1, DateTime.UtcNow), })); mux.ExpectMsg(Timeout); // PostStop of the crashed incarnation // A later apply must load cleanly on the restarted incarnation… host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() })); var reg = mux.ExpectMsg(Timeout); reg.TagRefs.ShouldContain("M.T"); // …and the alarm must still work end to end. host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); var state = publish.ExpectMsg(Timeout); state.AlarmNodeId.ShouldBe("alm-1"); state.State.Active.ShouldBeTrue(); } [Fact] public void Factory_Props_survives_a_handler_throw_and_loads_cleanly_afterwards() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var built = 0; var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, () => { built++; return Pair(); })); RestartThenReload(host, publish, mux); built.ShouldBe(2, "the restarted incarnation built its own engine"); } [Fact] public void Legacy_instance_Props_survives_a_handler_throw_because_a_restart_only_detaches() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var (upstream, engine) = Pair(); var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine)); RestartThenReload(host, publish, mux); } [Fact] public void An_alarm_command_whose_store_hangs_is_bounded_and_the_mailbox_moves_on() { var publish = CreateTestProbe(); var mux = CreateTestProbe(); var store = new HangingSaveStore(); var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, () => { var up = new DependencyMuxTagUpstreamSource(); return (up, BuildEngine(up, store)); }, commandTimeout: TimeSpan.FromMilliseconds(300))); host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() })); mux.ExpectMsg(Timeout); host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); publish.ExpectMsg(Timeout).State.Active.ShouldBeTrue(); store.HangNext = true; host.Tell(new AlarmCommand("alm-1", "Acknowledge", "alice", "ack", null)); // The hung save would have suspended the mailbox indefinitely; the bound releases it, so the // next dependency value is processed and the alarm clears. host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow)); publish.ExpectMsg(Timeout).State.Active.ShouldBeFalse(); store.ObservedCancellation.ShouldBeTrue(); } /// Store whose next SaveAsync blocks until its token is cancelled. private sealed class HangingSaveStore : IAlarmStateStore { private readonly InMemoryAlarmStateStore _inner = new(); public volatile bool HangNext; public volatile bool ObservedCancellation; public Task LoadAsync(string alarmId, CancellationToken ct) => _inner.LoadAsync(alarmId, ct); public Task> LoadAllAsync(CancellationToken ct) => _inner.LoadAllAsync(ct); public Task RemoveAsync(string alarmId, CancellationToken ct) => _inner.RemoveAsync(alarmId, ct); public async Task SaveAsync(AlarmConditionState state, CancellationToken ct) { if (HangNext) { HangNext = false; try { await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct); } catch (OperationCanceledException) { ObservedCancellation = true; throw; } } await _inner.SaveAsync(state, ct); } } }