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.Messages.Fleet; 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.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Verifies the discovered-node injection wired into (Task 7): when a /// driver child reports a captured FixedTree via , /// the host resolves the bound equipment from the authored composition, maps the nodes under it via /// , materialises them on the OPC UA publish side /// (), extends the live-value routing map /// (_nodeIdByDriverRef), and merges the FixedTree refs into the driver's desired subscription set /// (). /// /// /// Drives a real apply through the existing harness (same artifact shape as /// DriverHostActorLiveValueTests / DriverHostActorWriteRoutingTests) so /// _lastComposition is set and a real (non-stubbed) child /// is spawned for d1. The child is backed by the shared /// (records LastSubscribedRefs/SubscribeCount, exactly as /// DriverInstanceActorTests asserts) so the merged subscription is observable; the OPC UA /// publish actor is a (as in /// DriverHostActorLiveValueTests) so the materialise + the post-injection value route are /// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful /// end-to-end approach the harness allows. /// /// [Trait("Category", "Unit")] public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64)); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the /// bound equipment: (a) the publish side receives /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the /// live-value routing map was extended). [Fact] public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode // carries no DriverInstanceId). var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the // mapper keeps it (it does not shadow an authored ref). var discovered = new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), }; actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1". var materialise = publish.ExpectMsg(Timeout); materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); materialise.Variables.Count.ShouldBe(1); materialise.Folders.Count.ShouldBeGreaterThan(0); var fixedTreeNodeId = materialise.Variables[0].NodeId; // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both. AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldContain("ft-ref-1"); }, duration: Timeout); // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the // _nodeIdByDriverRef live-value map was extended by the injection. actor.Tell(new DriverInstanceActor.AttributeValuePublished( "d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); var update = publish.ExpectMsg(Timeout); update.NodeId.ShouldBe(fixedTreeNodeId); update.Value.ShouldBe(42.0); update.Quality.ShouldBe(OpcUaQuality.Good); update.TimestampUtc.ShouldBe(Ts); } /// NEW capability (follow-up E): a driver bound to an equipment via /// with ZERO authored equipment tags can still graft its /// discovered FixedTree. The equipment is resolved from the composition's EquipmentNodes (not just /// the authored EquipmentTags), so a tag-less equipment receives /// rooted at its NodeId and the driver /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with /// "no equipment/authored tags". [Fact] public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1. var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1. var materialise = publish.ExpectMsg(Timeout); materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); materialise.Variables.Count.ShouldBe(1); var fixedTreeNodeId = materialise.Variables[0].NodeId; // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref). AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("ft-ref-1"); }, duration: Timeout); // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended). actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); var update = publish.ExpectMsg(Timeout); update.NodeId.ShouldBe(fixedTreeNodeId); update.Value.ShouldBe(42.0); } /// DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE /// equipment but where NONE of the candidates carries a has nothing /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1 /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly /// the degenerate shape: no is told. [Fact] public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip. var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"), (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to /// a DEVICE with a distinct , partitions its discovered FixedTree by /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose /// DeviceHost matches. Asserts (a) TWO (one per /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" /// vs stored "h1" wrinkle proves the SHARED match. [Fact] public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode. var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost. actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "H1", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment. var m1 = publish.ExpectMsg(Timeout); var m2 = publish.ExpectMsg(Timeout); var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m); byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model"); byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries no host. eqANodeId.ShouldStartWith("EQ-A/"); eqANodeId.ShouldNotContain("h1"); eqBNodeId.ShouldStartWith("EQ-B/"); eqBNodeId.ShouldNotContain("h2"); // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("ft-h1-1"); refs.ShouldContain("ft-h2-1"); }, duration: Timeout); // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node. actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts)); publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqANodeId); actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts)); publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqBNodeId); } /// Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). [Fact] public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise. var m1 = publish.ExpectMsg(Timeout); var m2 = publish.ExpectMsg(Timeout); new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash. actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts)); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// Guard: a arriving BEFORE any deployment /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish /// side (the equipment can't be resolved without a composition). [Fact] public void DiscoveredNodes_before_any_apply_are_ignored() { var db = NewInMemoryDbFactory(); var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); var vtHost = CreateTestProbe(); // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null. var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: new SubscribingDriverFactory("Modbus"), localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref, virtualTagHostOverride: vtHost.Ref)); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Dedup: a discovered node whose FullReference equals an authored equipment tag's /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs /// are materialised. [Fact] public void Discovered_node_shadowing_an_authored_ref_is_not_injected() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new. actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" }, BrowseName: "speed", DisplayName: "Speed", FullReference: "40001", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed". var materialise = publish.ExpectMsg(Timeout); materialise.Variables.Count.ShouldBe(1); materialise.Variables[0].DisplayName.ShouldBe("Model"); } /// Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising /// FixedTree still converges. [Fact] public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); var node1 = new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false); var node2 = new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" }, BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false); // Pass 1: a new set ⇒ one materialise + a union re-subscribe. actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); publish.ExpectMsg(Timeout); AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("ft-ref-1"); }, duration: Timeout); var subscribeCountAfterFirst = factory.SubscribeCount; // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe). actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst); // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs). actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 })); publish.ExpectMsg(Timeout).Variables.Count.ShouldBe(2); AwaitAssert(() => { factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst); var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("ft-ref-1"); refs.ShouldContain("ft-ref-2"); }, duration: Timeout); } /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. [Fact] public void Discovered_nodes_survive_a_redeploy_rebuild() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); var discovered = new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), }; actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts. var materialise1 = publish.ExpectMsg(Timeout); materialise1.EquipmentRootNodeId.ShouldBe("EQ-1"); materialise1.Variables.Count.ShouldBe(1); var fixedTreeNodeId = materialise1.Variables[0].NodeId; // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); // The redeploy fires a fresh RebuildAddressSpace first (drain it) ... publish.ExpectMsg(Timeout); // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply), // at the SAME NodeId the first injection placed it. var materialise2 = publish.ExpectMsg(Timeout); materialise2.EquipmentRootNodeId.ShouldBe("EQ-1"); materialise2.Variables.Count.ShouldBe(1); materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId); // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear(). AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldContain("ft-ref-1"); }, duration: Timeout); // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()). actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); var update = publish.ExpectMsg(Timeout); update.NodeId.ShouldBe(fixedTreeNodeId); update.Value.ShouldBe(42.0); update.Quality.ShouldBe(OpcUaQuality.Good); } /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell /// for the now-unresolved driver. [Fact] public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // First injection materialises under EQ-1. publish.ExpectMsg(Timeout); // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is // re-told (the cached d1 plan was dropped because its equipment no longer resolves). publish.ExpectMsg(Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and /// drops the entry. After the redeploy NO is /// re-told. (Complements , which /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) [Fact] public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1). publish.ExpectMsg(Timeout); // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the // guard's known-limitation comment describes. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/"). publish.ExpectMsg(Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent /// until the driver's next natural reconnect). Wires a REAL child (policy /// , ever-growing set) so the connect-time pass populates the /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig /// (so ReconcileDrivers does NOT restart the child — exactly the config-unchanged rebind). The /// re-inject tail drops the stale EQ-1 entry and must send /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there /// is no probe-as-driver-child seam): the child re-runs discovery (its DiscoverCount advances past the /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks /// discovery) AND a fresh re-grafts the FixedTree /// under the new equipment EQ-2. [Fact] public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() { var db = NewInMemoryDbFactory(); // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a // TriggerRediscovery re-runs discovery, making the trigger observable at the host level. var factory = new DiscoverableSubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); var vtHost = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref, virtualTagHostOverride: vtHost.Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }). publish.FishForMessage( m => m.EquipmentRootNodeId == "EQ-1", Timeout); AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒ // DiscoverCount stays 1 ⇒ this fails.) AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10)); // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as // { EQ-2: plan-scoped-to-EQ-2 }. publish.FishForMessage( m => m.EquipmentRootNodeId == "EQ-2", Timeout); var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass) // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger, // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no // fresh discovery pass runs. var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC, (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger). publish.ExpectMsg(Timeout) .EquipmentRootNodeId.ShouldBe("EQ-2"); // No further publish traffic and NO further discovery pass — the re-trigger converged. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); factory.DiscoverCount.ShouldBe(discoverCountAfterRebind); } /// Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so droppedAny /// stays false and no is sent. Uses the REAL /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver, /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once /// pass. [Fact] public void No_drop_redeploy_does_not_re_trigger_discovery() { var db = NewInMemoryDbFactory(); var factory = new DiscoverableSubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); var vtHost = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref, virtualTagHostOverride: vtHost.Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1). publish.FishForMessage( m => m.EquipmentRootNodeId == "EQ-1", Timeout); AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop). var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises. publish.ExpectMsg(Timeout) .EquipmentRootNodeId.ShouldBe("EQ-1"); // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no // further publish traffic arrives. DiscoverCount stays 1. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); factory.DiscoverCount.ShouldBe(1); } /// Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose /// plan SURVIVES a redeploy must receive EXACTLY ONE /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via /// the shared driver's SubscribeCount (each non-empty SetDesiredSubscriptions ⇒ exactly one /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) [Fact] public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is // populated (_discoveredByDriver[d1] = { EQ-1: plan }). publish.ExpectMsg(Timeout); AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldContain("ft-ref-1"); }, duration: Timeout); // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); var countBeforeRedeploy = factory.SubscribeCount; // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan // SURVIVES the re-inject tail ⇒ re-applied as a single union send). var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); publish.ExpectMsg(Timeout); // tail survivor re-materialise // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix). AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldContain("ft-ref-1"); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); }, duration: Timeout); // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); } /// Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from _discoveredByDriver) /// must still receive EXACTLY ONE — the AUTHORED-ONLY /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also /// gets a — a different message type the non-discovery /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. [Fact] public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); publish.ExpectMsg(Timeout); AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldContain("ft-ref-1"); }, duration: Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); var countBeforeRedeploy = factory.SubscribeCount; // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted). // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001" // stays subscribed this pass. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic. publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and // the set is "40001" only (the dropped FixedTree ref is gone). AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.ShouldNotContain("ft-ref-1"); refs.Count.ShouldBe(1); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); }, duration: Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); } /// Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk /// authored-only per redeploy (the re-inject tail /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). [Fact] public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then // snapshot the count as the redeploy baseline. AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); }, duration: Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); var countBeforeRedeploy = factory.SubscribeCount; // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and // the re-inject tail skips it. var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("40001"); refs.Count.ShouldBe(1); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); }, duration: Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); } /// Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via /// ) whose FixedTree plan is FULLY DROPPED by a rebind redeploy /// receives an EMPTY authored-only fallback , which /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the /// SubscribeCount-proxy blind spot for the empty-set fallback. [Fact] public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags. var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { new DiscoveredNode( FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), })); // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }. publish.ExpectMsg(Timeout); AwaitAssert(() => { var refs = factory.LastSubscribedRefs; refs.ShouldNotBeNull(); refs!.ShouldContain("ft-ref-1"); }, duration: Timeout); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); var countBeforeRedeploy = factory.SubscribeCount; // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes. var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1"); actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); // No re-materialise — the cached plan was dropped (not re-grafted). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or // spurious subscribe — the bug this guards against — would increment it.) factory.SubscribeCount.ShouldBe(countBeforeRedeploy); } /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the /// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A /// VirtualTag-host probe is injected so the real host isn't spawned. The /// that lands on the publish probe during apply is drained so the test's materialise / value-update /// assertions see only post-apply traffic. private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply( IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) { var coordinator = CreateTestProbe(); var publish = CreateTestProbe(); var vtHost = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, coordinator.Ref, driverFactory: factory, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref, virtualTagHostOverride: vtHost.Ref)); actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); publish.ExpectMsg(Timeout); return (actor, publish, coordinator); } /// /// Seeds a Sealed deployment whose artifact carries the minimal arrays /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, plus a /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag so /// a REAL (non-stubbed) child is spawned (mirrors /// DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags). /// private static DeploymentId SeedDeploymentWithEquipmentTags( IDbContextFactory db, RevisionHash rev, params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) { var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 }, DriverInstances = driverIds.Select(d => new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = d, Name = d, DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", NamespaceId = "ns-eq", }).ToArray(), Tags = tags.Select((t, i) => new { TagId = $"tag-{i}", EquipmentId = t.Equip, DriverInstanceId = t.Driver, Name = t.Name, FolderPath = t.Folder, DataType = "Double", TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), }).ToArray(), }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } /// /// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the /// Equipment row's DriverInstanceId (the /// projection) but carries NO authored equipment tags — so the equipment can only be resolved from /// EquipmentNodes, not EquipmentTags. A DriverInstances row (non-Windows-only /// "Modbus", Enabled) is included so a REAL (non-stubbed) child is /// spawned for the driver even though it has zero tags. /// private static DeploymentId SeedDeploymentWithTagLessEquipment( IDbContextFactory db, RevisionHash rev, string equipmentId, string driverId) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = driverId, Name = driverId, DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", NamespaceId = "ns-eq", }, }, // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags. Equipment = new[] { new { EquipmentId = equipmentId, Name = equipmentId, UnsLineId = (string?)null, DriverInstanceId = driverId, DeviceId = (string?)null, }, }, Tags = Array.Empty(), }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } /// /// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a /// distinct Device row whose DeviceConfig carries a HostAddress — so each /// resolves a distinct (the shape /// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are /// resolved purely from EquipmentNodes. The driver row is non-Windows-only ("Modbus", Enabled) /// so a REAL (non-stubbed) child is spawned. /// private static DeploymentId SeedDeploymentWithMultiDeviceEquipments( IDbContextFactory db, RevisionHash rev, string driverId, params (string Equip, string DeviceId, string Host)[] equipments) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 }, DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = driverId, Name = driverId, DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) Enabled = true, DriverConfig = "{}", NamespaceId = "ns-eq", }, }, // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor). Devices = equipments.Select(e => new { DeviceId = e.DeviceId, DriverInstanceId = driverId, Name = e.DeviceId, DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }), }).ToArray(), // Each equipment binds to the driver AND a device — the multi-device resolution path. Equipment = equipments.Select(e => new { EquipmentId = e.Equip, Name = e.Equip, UnsLineId = (string?)null, DriverInstanceId = driverId, DeviceId = e.DeviceId, }).ToArray(), Tags = Array.Empty(), }); var id = DeploymentId.NewId(); using var ctx = db.CreateDbContext(); ctx.Deployments.Add(new Deployment { DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed, CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, }); ctx.SaveChanges(); return id; } /// Factory producing a single shared for the supported /// type, exposing its most-recent subscribed reference set for assertions (mirrors /// DriverHostActorWriteRoutingTests.RecordingDriverFactory, but the driver is /// so the merged subscription is observable). private sealed class SubscribingDriverFactory : IDriverFactory { private readonly string _supportedType; private readonly SubscribableStubDriver _driver = new(); public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } /// The reference set passed to the driver's most recent SubscribeAsync call. public IReadOnlyList? LastSubscribedRefs => _driver.LastSubscribedRefs; /// Number of SubscribeAsync calls so far — lets a test prove a redundant re-apply did /// NOT force a (drop-then-)re-subscribe of the whole handle. public int SubscribeCount => _driver.SubscribeCount; /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; /// public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; } /// Factory producing a single shared for the supported /// type — a real (non-stubbed) child that exposes , /// so the host's post-connect discovery loop populates the discovered-node cache AND a /// re-runs discovery (the seam the rebind re-trigger /// test asserts through). Exposes the driver's pass count so a test can observe the trigger landing. private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory { private readonly string _supportedType; private DiscoverableSubscribingDriver? _driver; public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } /// Number of DiscoverAsync passes the child has driven (advances on every /// connect-time pass and every -driven pass). public int DiscoverCount => _driver?.DiscoverCount ?? 0; /// public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId) : null; /// public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; } /// A that is BOTH (so the host's subscribe /// path is exercised) and with policy /// — exactly one post-connect pass, re-runnable by . Each /// pass streams an ever-growing FixedTree (pass N → N nodes, refs "ft-ref-1".."ft-ref-N", none shadowing the /// authored "40001"), so a re-triggered pass yields a grown set the host re-applies — and the public /// makes the trigger observable at the host level. Re-implements /// (the base hardcodes "stub-driver-1") so the /// spawned child reports the SPEC's id — otherwise its auto-sent /// would carry the wrong driver id and resolve no equipment. private sealed class DiscoverableSubscribingDriver : StubDriver, IDriver, ISubscribable, ITagDiscovery { private int _passCount; private readonly string _driverInstanceId; private readonly StubHandle _handle = new(); public DiscoverableSubscribingDriver(string driverInstanceId) => _driverInstanceId = driverInstanceId; /// The spec's driver instance id (re-mapped from the base "stub-driver-1"). public new string DriverInstanceId => _driverInstanceId; /// Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; /// Number of DiscoverAsync passes driven so far. public int DiscoverCount => Volatile.Read(ref _passCount); /// Never raised (the test asserts on discovery + materialise, not data changes); explicit /// empty accessors satisfy the interface without a never-used backing field (no CS0067). public event EventHandler? OnDataChange { add { } remove { } } /// public Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => Task.FromResult(_handle); /// public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; /// Streams an ever-growing FixedTree (pass N → N nodes). public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { var pass = Interlocked.Increment(ref _passCount); var fixedTree = builder.Folder("FixedTree", "FixedTree"); for (var i = 0; i < pass; i++) { fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( FullName: $"ft-ref-{i + 1}", DriverDataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); } return Task.CompletedTask; } } }