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; /// /// v3 Batch 4 (B4-WP3) — the driver single-source fan-out wired into /// . The driver publishes a value keyed by its wire-ref (== the tag's /// RawPath); the host's _nodeIdByDriverRef map — rebuilt each apply from the composition's /// RawTags ∪ UnsReferenceVariables — resolves (DriverInstanceId, RawPath) to the RAW /// NodeId AND every referencing UNS NodeId, so ForwardBatchToMux lands ONE write on the raw node /// () plus every UNS node () with /// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded /// to the dependency mux (VirtualTag inputs), keyed by the RawPath. /// /// Driven over the BATCH ingress, because that is the only one a driver child has: since the /// value-pipeline batching work DriverInstanceActor.OnDataChangeForward wraps even a single /// data change in an , and the host /// no longer receives the per-value message at all. These fan-out cases are therefore stated as /// single-item bursts; what they pin is the ROUTING, while /// pins the message counts of a multi-item burst. /// /// /// Drives a real apply through : the seeded artifact carries the v3 /// raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional /// UnsTagReference projecting each raw tag into an equipment. DispatchDeployment triggers the /// BeginDirectApply → HandleArtifactForApply → PushDesiredSubscriptionsFromParsed pass that /// builds the map; the sink and mux are /// injected as s. /// /// public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-lv-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc); /// The single-item burst a driver child emits for one data change — every value ingress into /// the host is an AttributeValueBatchPublished, so the fan-out cases below state their input that /// way. private static DriverInstanceActor.AttributeValueBatchPublished Burst((string Ref, object Value) item) => new("drv-1", new[] { new DriverInstanceActor.AttributeValueItem(item.Ref, item.Value, OpcUaStatus.Good, Ts) }); /// A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each /// carrying identical value/quality/timestamp — the fan-out drift guard. [Fact] public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) }); var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); // The driver publishes by its wire-ref RawPath, in the single-item burst DriverInstanceActor // emits for one data change. actor.Tell(Burst(("Plant/Modbus/dev1/speed", 42.0))); var byId = publish.ExpectMsg(PresenceBudget) .Items.ToDictionary(u => u.NodeId); byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true); // Realm travels with each update so the sink resolves the right namespace. byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw); byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns); // Fan-out drift guard: identical value + quality + timestamp on BOTH node states. foreach (var u in byId.Values) { u.Value.ShouldBe(42.0); u.StatusCode.ShouldBe(OpcUaStatus.Good); u.SourceTimestampUtc.ShouldBe(Ts); } // The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath. mux.ExpectMsg(PresenceBudget) .Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed"); } /// A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm). [Fact] public void Unreferenced_raw_tag_fans_to_raw_node_only() { 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(Burst(("Plant/Modbus/dev1/speed", 5.0))); var only = publish.ExpectMsg(PresenceBudget) .Items.ShouldHaveSingleItem(); only.NodeId.ShouldBe("Plant/Modbus/dev1/speed"); only.Realm.ShouldBe(AddressSpaceRealm.Raw); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates, /// identical value) — the 1:N fan-out. [Fact] public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns() { var db = NewInMemoryDbFactory(); var deploymentId = SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null), (Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null), }); var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(Burst(("Plant/Modbus/dev1/speed", 7.0))); // 3-wide fan-out inside ONE batch message — the batch path's own 1:N coverage. var updates = publish.ExpectMsg(PresenceBudget).Items; updates.Count.ShouldBe(3); updates.Select(u => u.NodeId).ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed", "filling/line1/station2/speed", }, ignoreOrder: true); updates.ShouldAllBe(u => (double)u.Value! == 7.0); } /// A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching /// NodeId), but is still forwarded to the dependency mux. [Fact] public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux() { 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(Burst(("Plant/Modbus/dev1/nope", 99.0))); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); mux.ExpectMsg(PresenceBudget) .Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/nope"); } // ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------ // // #478 gave a scripted condition the worst quality of its inputs, but only for inputs whose driver // PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it // raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value and // went on evaluating it. These pin the synthetic Bad that closes that hole. /// Comms loss pushes a Bad for every value ref the driver owns — not one, and not the /// alarm-bearing ones (those take the Part 9 quality path instead) — as ONE batch envelope rather than /// one Tell per ref. [Fact] public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns() { 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.ConnectivityChanged("drv-1", Connected: false)); // ONE envelope for the whole driver: a driver owning 5k tags cost 5k Tells before this was batched. var fan = mux.ExpectMsg(PresenceBudget); fan.DriverInstanceId.ShouldBe("drv-1"); var pushes = fan.Items; pushes.Select(p => p.FullReference).ShouldBe( new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true); foreach (var p in pushes) { // BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code // names the actual cause and reaches a client through the scripted condition's quality. p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); // Null rather than the last-known value: the honest statement is "no current value". The // engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null. p.Value.ShouldBeNull(); } mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// Reconnect pushes NOTHING — and this is the falsifying control for the test above, since /// both drive the same handler over the same populated map and only one may produce traffic. [Fact] public void Driver_reconnect_pushes_nothing_into_the_mux() { // Publishing Good here would assert a value quality the host has not observed. Recovery instead // rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription, // so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with // forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll // engine would compare against retained LastValues, see an unchanged value, raise nothing, and // strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert. 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) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true)); mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is /// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags. [Fact] public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux() { 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) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA); actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false)); mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } }