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 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); } /// Multi-equipment-per-driver still warn+skips (the multi-device partition is the NEXT follow-up /// task). A driver that resolves to MORE THAN ONE equipment injects nothing yet: no /// is told. [Fact] public void Driver_mapping_to_more_than_one_equipment_still_warn_skips() { var db = NewInMemoryDbFactory(); var factory = new SubscribingDriverFactory("Modbus"); // d1 is bound to TWO equipments via two authored tags ⇒ equipmentIds.Count == 2 ⇒ 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 — the >1-equipment branch warn+skips (replaced by the multi-device task). publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// 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). publish.FishForMessage( m => m.EquipmentRootNodeId == "EQ-2", Timeout); } /// 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; } /// 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; } } }