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; /// /// Batched value fan-out in : a driver child's /// burst costs exactly ONE mux hop and /// ONE OPC UA publish hop, regardless of how many values it carries — the point of the batching work. /// /// The routing itself is unchanged from the per-value path: each item's FullReference /// (== the tag's RawPath) resolves through the host's _nodeIdByDriverRef map to the RAW NodeId /// AND every referencing UNS NodeId, each carrying IDENTICAL value/status/timestamp (v3 Batch 4 /// single-source fan-out), and an unmapped ref is dropped rather than faulting the burst. These pin /// the message COUNT as well as the contents, because a per-item unwrap satisfies the contents alone. /// /// /// Arrangement mirrors — a real apply through the seeded /// v3 artifact () builds the map; sink + mux are /// s. /// /// public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-batch-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('b', 64)); private static readonly DateTime TsA = new(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc); private static readonly DateTime TsB = new(2026, 8, 15, 9, 0, 1, DateTimeKind.Utc); /// A two-item burst whose first item is referenced by one equipment lands as a SINGLE /// AttributeValueBatchUpdate carrying all three (raw, uns, raw) writes verbatim — including a non-Good /// sub-code (#519), which rides through unbucketed. [Fact] public void Batch_fans_out_to_all_node_realm_refs_in_one_publish_message() { 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: 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.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.BadNoCommunication, TsB), })); var batch = publish.ExpectMsg(PresenceBudget); batch.Items.Count.ShouldBe(3); var byId = batch.Items.ToDictionary(w => w.NodeId); byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed", "Plant/Modbus/dev1/temp", }, ignoreOrder: true); // Item A: raw + UNS, identical value/quality/timestamp (fan-out drift guard). foreach (var id in new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }) { byId[id].Value.ShouldBe(42.0); byId[id].StatusCode.ShouldBe(OpcUaStatus.Good); byId[id].SourceTimestampUtc.ShouldBe(TsA); } byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw); byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns); // Item B: raw only, and its own status sub-code + timestamp — not item A's. byId["Plant/Modbus/dev1/temp"].Value.ShouldBe(7.5); byId["Plant/Modbus/dev1/temp"].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); byId["Plant/Modbus/dev1/temp"].SourceTimestampUtc.ShouldBe(TsB); byId["Plant/Modbus/dev1/temp"].Realm.ShouldBe(AddressSpaceRealm.Raw); // ONE message, not one per value. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// An item whose ref is bound to no NodeId is dropped; the rest of the burst still lands in the /// one message and nothing faults. [Fact] public void Unmapped_items_are_dropped_not_faulted() { 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.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, TsA), new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), })); var batch = publish.ExpectMsg(PresenceBudget); batch.Items.Count.ShouldBe(1); batch.Items[0].NodeId.ShouldBe("Plant/Modbus/dev1/speed"); batch.Items[0].Value.ShouldBe(42.0); // The host is still alive and routing after the unmapped item. actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 43.0, OpcUaStatus.Good, TsB), })); publish.ExpectMsg(PresenceBudget) .Items[0].Value.ShouldBe(43.0); } /// The dependency mux gets ONE envelope carrying the whole burst — not N per-item messages. /// The mux filters per item itself. [Fact] public void Batch_reaches_mux_once() { 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 (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsB), })); var fwd = mux.ExpectMsg(PresenceBudget); fwd.DriverInstanceId.ShouldBe("drv-1"); fwd.Items.Select(i => i.FullReference).ShouldBe( new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }); mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// A burst in which NOTHING resolves sends no publish message at all — an empty batch would be /// wasted work at the sink — while the mux still gets its one envelope (virtual-tag inputs are keyed by /// ref, independent of whether the ref is materialised as a node). [Fact] public void All_unmapped_batch_sends_nothing_to_publish_actor() { 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, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 1.0, OpcUaStatus.Good, TsA), new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/alsonope", 2.0, OpcUaStatus.Good, TsB), })); mux.ExpectMsg(PresenceBudget) .Items.Count.ShouldBe(2); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// A host with NO OPC UA publish actor wired (the ctor default — the dev/Mac shape) still /// makes the mux hop: the batch fan-out returns early on a null sink, and returning early BEFORE the /// mux Tell would silently strand virtual-tag and continuous-historization inputs. [Fact] public void Mux_still_receives_batch_when_no_publish_actor_is_wired() { 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, mux) = SpawnHostAndApplyWithoutPublishActor(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[] { new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA), })); var fwd = mux.ExpectMsg(PresenceBudget); fwd.DriverInstanceId.ShouldBe("drv-1"); fwd.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed"); } }