using Akka.Actor; using Akka.TestKit; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// The comms-loss fan (DriverHostActor.FanCommsLossToDependencyMux) end-to-end through a REAL /// , rather than the mux probe the other host suites use. /// /// Two properties are pinned here that a probe cannot show. First, the fan is ONE /// for the whole driver rather than one /// Tell per ref — a driver owning 5k tags used to cost 5k messages on a single connectivity edge — and /// the mux's batch path still delivers every item to an interested subscriber, so batching did not /// quietly drop the delivery it exists to make. /// /// /// Second, the driver scoping: the fan resolves its refs through a per-driver index built alongside /// _nodeIdByDriverRef instead of scanning every entry in it, and an index is exactly the thing /// that can key a lookup wrong. The two-driver case is therefore the falsification control — the /// untouched driver's ref must be absent from the batch, which a scan-and-filter implementation and a /// correctly-keyed index both satisfy but a mis-keyed one does not. /// /// public sealed class DriverHostActorCommsLossTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('c', 64)); /// Spawns a host whose dependency mux is a REAL , applies the /// seeded deployment, and registers 's interest in . /// /// The factory whose deployment was seeded by . /// The deployment to dispatch. /// The probe standing in for a VirtualTagActor / scripted-alarm subscriber. /// The RawPaths that subscriber registers interest in. /// The applied host actor. private IActorRef SpawnHostWithRealMux( IDbContextFactory db, DeploymentId deploymentId, TestProbe subscriber, params string[] refs) { var coordinator = CreateTestProbe(); var mux = Sys.ActorOf(DependencyMuxActor.Props()); mux.Tell(new DependencyMuxActor.RegisterInterest(refs, subscriber.Ref)); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, dependencyMux: mux, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, virtualTagHostOverride: CreateTestProbe().Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(PresenceBudget).Outcome.ShouldBe(ApplyAckOutcome.Applied); return actor; } /// A driver's comms loss reaches the mux as ONE batch carrying a Bad item per ref it owns, and /// the mux delivers that whole slice to an interested subscriber in ONE /// . [Fact] public void Comms_loss_reaches_the_mux_as_one_batch_of_bad_items() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false), (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false), }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); var subscriber = CreateTestProbe(); var actor = SpawnHostWithRealMux(db, deploymentId, subscriber, "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp"); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); var batch = subscriber.ExpectMsg(PresenceBudget); batch.Items.Select(i => i.TagId).ShouldBe( new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true); foreach (var item in batch.Items) { item.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); item.Value.ShouldBeNull(); } // ONE batch, not one per ref: the whole point of the change. A per-ref fan would land a second // single-item DependencyValueBatch here. subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// Falsification control for the per-driver index: with TWO drivers configured, one driver's /// comms loss carries ONLY its own refs — the other driver's tag is untouched. [Fact] public void Comms_loss_batch_carries_only_the_dead_drivers_refs() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false), (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false), (Driver: "drv-2", DriverName: "Modbus2", Device: "dev2", Tag: "flow", DataType: "Double", Writable: false), }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); var subscriber = CreateTestProbe(); var actor = SpawnHostWithRealMux(db, deploymentId, subscriber, "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp", "Plant/Modbus2/dev2/flow"); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); var batch = subscriber.ExpectMsg(PresenceBudget); batch.Items.Select(i => i.TagId).ShouldBe( new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true); subscriber.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } }