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);
}
}
}