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);
}
/// 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). 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);
}
/// 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