using System.Text.Json; using Akka.Actor; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Review I-2: under the factory Props a supervision restart of the scripted-alarm host builds a fresh, EMPTY /// engine. Nothing re-sent ApplyScriptedAlarms, so until the next deploy the host had no mux interest /// (inputs stopped) and its ownership filter dropped every operator ack/shelve as "not owned". The host now /// tells its parent from PostRestart, /// and re-sends the last plans it applied. This test never re-applies by hand. /// public sealed class DriverHostActorScriptedAlarmRestartTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-alarmhost-restart-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); private static DeploymentId SeedAlarmDeployment(Microsoft.EntityFrameworkCore.IDbContextFactory db) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { ScriptedAlarms = new[] { new { ScriptedAlarmId = "alm-1", EquipmentId = "eq-1", Name = "HighTemp", AlarmType = "AlarmCondition", Severity = 800, MessageTemplate = "hot", PredicateScriptId = "s-1", HistorizeToAveva = false, Retain = true, Enabled = true, }, }, Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return (int)ctx.GetTag(\"M.T\").Value > 90;" } }, }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = RevA.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } [Fact] public void After_a_supervision_restart_the_alarm_host_is_re_applied_and_inputs_and_acks_work_again() { var db = NewInMemoryDbFactory(); var deploymentId = SeedAlarmDeployment(db); var publish = this.CreateCleanOutcomePublishProbe(); var mux = CreateTestProbe(); var coordinator = CreateTestProbe(); var host = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, dependencyMux: mux.Ref, opcUaPublishActor: publish.Ref, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, virtualTagHostOverride: CreateTestProbe().Ref, scriptRootLogger: new ScriptRootLogger(new LoggerConfiguration().CreateLogger()), alarmStateStore: new InMemoryAlarmStateStore())); V3ArtifactSeeder.Dispatch(host, coordinator, deploymentId, RevA); var first = mux.FishForMessage(r => r.TagRefs.Contains("M.T"), Timeout); var alarmHost = first.Subscriber; // The crash: a batch item with a null TagId makes the upstream Push throw (the documented unguarded // path), so supervision restarts the alarm host. NO manual re-apply follows. alarmHost.Tell(new VirtualTagActor.DependencyValueBatch(new[] { new VirtualTagActor.DependencyValueChanged(null!, 1, DateTime.UtcNow), })); mux.FishForMessage(u => u.Subscriber.Equals(alarmHost), Timeout); // The parent re-applies on the restart signal: interest is registered again by the new incarnation. mux.FishForMessage(r => r.TagRefs.Contains("M.T"), Timeout); // Inputs work: a dependency push activates the alarm. alarmHost.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); publish.FishForMessage( m => m.AlarmNodeId == "alm-1" && m.State.Active && !m.State.Acknowledged, Timeout); // Acks work: the restarted engine owns alm-1 again, so the command is not filtered out as unowned. alarmHost.Tell(new AlarmCommand(AlarmId: "alm-1", Operation: "Acknowledge", User: "alice", Comment: null, UnshelveAtUtc: null)); publish.FishForMessage( m => m.AlarmNodeId == "alm-1" && m.State.Acknowledged, Timeout) .State.Acknowledged.ShouldBeTrue(); } }