using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; 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; /// /// Archreview 03/S15 — the value-node half of the comms-loss fan-out. Until this, a driver /// declared comms-lost annotated its Part 9 conditions and pushed a synthetic Bad into the dependency /// mux, but its raw and UNS value variables — the product's external surface — got nothing: an /// OPC UA client subscribed to a value node saw the last value at Good quality for the whole /// outage, for every driver whose failure shape is "reader stops producing" rather than "per-tag Bad /// snapshot". /// /// Status-only, value retained. The write goes through /// so the node keeps its last known value /// under BadNoCommunication (OPC UA Part 8's convention). The host holds no value cache — v3's /// single-source design — so only the node itself can retain it. /// /// Nothing is published on reconnect, deliberately: publishing Good would assert a /// quality this actor has not observed. Recovery is the driver's own re-publish (the poll engine's /// forceRaise on a fresh subscription, or the push-model initial push). That contract is /// end-to-end tested in . /// public sealed class DriverHostActorCommsLossValueNodesTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-commsloss-values"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('c', 64)); /// /// A disconnect produces ONE status batch naming exactly the disconnected driver's raw nodes plus /// every referencing UNS mirror, all BadNoCommunication. The second driver's ref is the /// control: a fan-out that walked every value ref on the node instead of the per-driver index would /// mark a perfectly healthy driver's nodes Bad, and no assertion about the first driver's nodes /// would notice. /// [Fact] public void Disconnect_marks_the_drivers_raw_and_uns_value_nodes_BadNoCommunication() { 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: "S7", Device: "dev2", Tag: "other", DataType: "Double", Writable: false), }, unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) }); var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); var batch = publish.ExpectMsg(PresenceBudget); batch.Items.Select(i => i.NodeId).ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed", "Plant/Modbus/dev1/temp", }, ignoreOrder: true); batch.Items.ShouldAllBe(i => i.StatusCode == OpcUaStatus.BadNoCommunication); batch.Items.Single(i => i.NodeId == "Plant/Modbus/dev1/speed").Realm.ShouldBe(AddressSpaceRealm.Raw); batch.Items.Single(i => i.NodeId == "filling/line1/station1/speed").Realm.ShouldBe(AddressSpaceRealm.Uns); // The control: the OTHER driver's node is untouched by drv-1's outage. batch.Items.ShouldNotContain(i => i.NodeId == "Plant/S7/dev2/other"); // ONE batch, not one message per node. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// /// A reconnect publishes nothing to the value nodes. Writing Good here would claim a quality /// the host has not observed — the device may be answering the transport and still returning /// garbage — and it would race the driver's own first real value. /// [Fact] public void Reconnect_publishes_no_value_or_status_message() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false), }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false)); publish.ExpectMsg(PresenceBudget); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true)); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// /// A driver that owns no value refs produces no message at all — an empty batch would cost a hop /// and a sink call for nothing on every flap of an alarm-only or freshly-added driver. /// [Fact] public void A_driver_with_no_value_refs_publishes_nothing() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false), }, unsRefs: Array.Empty<(string, string, string, string, string?)>()); var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-unknown", Connected: false)); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } }