using System.Text.Json; using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DriverHostActorTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); /// Verifies that bootstrap with no prior state enters the Steady state. [Fact] public void Bootstrap_with_no_prior_state_enters_Steady() { var db = NewInMemoryDbFactory(); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // No-rev Steady: an incoming dispatch should be processed as a fresh apply, not a no-op. var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.Sealed); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); ack.NodeId.ShouldBe(TestNode); } /// Verifies that dispatching the same revision is acked immediately without apply work. [Fact] public void Same_revision_dispatch_is_acked_immediately_with_no_apply_work() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.Sealed); // Seed an Applied NodeDeploymentState for self at RevA so PreStart recovers Steady@RevA. using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied, AppliedAtUtc = DateTime.UtcNow.AddMinutes(-1), }); ctx.SaveChanges(); } var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // Dispatch the SAME deployment again. actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); // No new NodeDeploymentState row got added — the rev matched, so nothing changed. using var verify = db.CreateDbContext(); verify.NodeDeploymentStates.Count(s => s.NodeId == TestNode.Value).ShouldBe(1); } /// Verifies that a new revision dispatch writes an Applied NodeDeploymentState. [Fact] public void New_revision_dispatch_writes_Applied_NodeDeploymentState() { var db = NewInMemoryDbFactory(); var deploymentB = SeedDeployment(db, RevB, DeploymentStatus.Dispatching); var coordinator = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); actor.Tell(new DispatchDeployment(deploymentB, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); AwaitAssert(() => { using var verify = db.CreateDbContext(); var row = verify.NodeDeploymentStates.Single(s => s.NodeId == TestNode.Value && s.DeploymentId == deploymentB.Value); row.Status.ShouldBe(NodeDeploymentStatus.Applied); row.AppliedAtUtc.ShouldNotBeNull(); }, duration: TimeSpan.FromSeconds(3)); } /// Verifies that an orphaned Applying row on bootstrap is replayed. [Fact] public void Orphan_Applying_row_on_bootstrap_replays_apply() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeployment(db, RevA, DeploymentStatus.AwaitingApplyAcks); // Crash-orphan: a prior actor was mid-apply and never finished. using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying, StartedAtUtc = DateTime.UtcNow.AddMinutes(-2), }); ctx.SaveChanges(); } var coordinator = CreateTestProbe(); Sys.ActorOf(DriverHostActor.Props(db, TestNode, coordinator.Ref)); // PreStart should replay → ApplyAck back to coordinator with the new correlation id. var ack = coordinator.ExpectMsg(TimeSpan.FromSeconds(5)); ack.DeploymentId.ShouldBe(deploymentId); ack.Outcome.ShouldBe(ApplyAckOutcome.Applied); using var verify = db.CreateDbContext(); verify.NodeDeploymentStates.Single(s => s.NodeId == TestNode.Value && s.DeploymentId == deploymentId.Value) .Status.ShouldBe(NodeDeploymentStatus.Applied); } /// Fresh apply: dispatching a deployment whose artifact carries one Equipment /// ScriptedAlarm forwards a carrying that /// plan to the injected ScriptedAlarm host (via the scriptedAlarmHostOverride seam, mirroring /// the VirtualTag-host wiring). [Fact] public void Apply_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA); var coordinator = CreateTestProbe(); var alarmHost = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, scriptedAlarmHostOverride: alarmHost.Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied); var apply = alarmHost.ExpectMsg(TimeSpan.FromSeconds(5)); var plan = apply.Plans.ShouldHaveSingleItem(); plan.ScriptedAlarmId.ShouldBe("al-1"); plan.EquipmentId.ShouldBe("eq-1"); plan.Name.ShouldBe("Overheat"); plan.PredicateSource.ShouldContain("ctx.GetTag"); } /// Bootstrap-restore: a node that already has an Applied NodeDeploymentState /// row for a ScriptedAlarm-carrying deployment re-forwards the /// on PreStart (no dispatch needed), so a /// restarted node restores its live ScriptedAlarm children. [Fact] public void Restore_on_bootstrap_forwards_EquipmentScriptedAlarms_to_scripted_alarm_host() { var db = NewInMemoryDbFactory(); var deploymentId = SeedDeploymentWithScriptedAlarm(db, RevA); // Seed an Applied NodeDeploymentState row so Bootstrap() detects the Applied branch and // calls RestoreApplied — no DispatchDeployment needed. using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied, StartedAtUtc = DateTime.UtcNow, AppliedAtUtc = DateTime.UtcNow, }); ctx.SaveChanges(); } var coordinator = CreateTestProbe(); var alarmHost = CreateTestProbe(); // No DispatchDeployment — Bootstrap() should detect the Applied row and run RestoreApplied, // which routes through PushDesiredSubscriptions and forwards ApplyScriptedAlarms. Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, scriptedAlarmHostOverride: alarmHost.Ref)); var apply = alarmHost.ExpectMsg(TimeSpan.FromSeconds(5)); var plan = apply.Plans.ShouldHaveSingleItem(); plan.ScriptedAlarmId.ShouldBe("al-1"); plan.EquipmentId.ShouldBe("eq-1"); plan.Name.ShouldBe("Overheat"); plan.PredicateSource.ShouldContain("ctx.GetTag"); } private static DeploymentId SeedDeploymentWithScriptedAlarm( IDbContextFactory db, RevisionHash rev) { // Artifact carries a ScriptedAlarm joined (by PredicateScriptId) to its predicate Script — // the same shape ConfigComposer emits and DeploymentArtifact.ParseComposition decodes into // composition.EquipmentScriptedAlarms. var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Scripts = new[] { new { ScriptId = "scr-1", SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;", }, }, ScriptedAlarms = new[] { new { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Overheat", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "Machine 1 hot", PredicateScriptId = "scr-1", HistorizeToAveva = true, Retain = true, Enabled = true, }, }, }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Configuration.Entities.Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } private static DeploymentId SeedDeployment( IDbContextFactory db, RevisionHash rev, DeploymentStatus status) { var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Configuration.Entities.Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = status, CreatedBy = "test", SealedAtUtc = status == DeploymentStatus.Sealed ? DateTime.UtcNow : null, }); ctx.SaveChanges(); return id; } }