Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs
T

1028 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Verifies the discovered-node injection wired into <see cref="DriverHostActor"/> (Task 7): when a
/// driver child reports a captured FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>,
/// the host resolves the bound equipment from the authored composition, maps the nodes under it via
/// <see cref="DiscoveredNodeMapper"/>, materialises them on the OPC UA publish side
/// (<see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>), extends the live-value routing map
/// (<c>_nodeIdByDriverRef</c>), and merges the FixedTree refs into the driver's desired subscription set
/// (<see cref="DriverInstanceActor.SetDesiredSubscriptions"/>).
///
/// <para>
/// Drives a real apply through the existing harness (same artifact shape as
/// <c>DriverHostActorLiveValueTests</c> / <c>DriverHostActorWriteRoutingTests</c>) so
/// <c>_lastComposition</c> is set and a real (non-stubbed) <see cref="DriverInstanceActor"/> child
/// is spawned for <c>d1</c>. The child is backed by the shared <see cref="SubscribableStubDriver"/>
/// (records <c>LastSubscribedRefs</c>/<c>SubscribeCount</c>, exactly as
/// <c>DriverInstanceActorTests</c> asserts) so the merged subscription is observable; the OPC UA
/// publish actor is a <see cref="Akka.TestKit.TestProbe"/> (as in
/// <c>DriverHostActorLiveValueTests</c>) 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.
/// </para>
/// </summary>
[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);
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
/// 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).</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
update.NodeId.ShouldBe(fixedTreeNodeId);
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(Ts);
}
/// <summary>NEW capability (follow-up E): a driver bound to an equipment via
/// <see cref="EquipmentNode.DriverInstanceId"/> with ZERO authored equipment tags can still graft its
/// discovered FixedTree. The equipment is resolved from the composition's <c>EquipmentNodes</c> (not just
/// the authored <c>EquipmentTags</c>), so a tag-less equipment receives
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> 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".</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
update.NodeId.ShouldBe(fixedTreeNodeId);
update.Value.ShouldBe(42.0);
}
/// <summary>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
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is told.</summary>
[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));
}
/// <summary>Guard: a <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving BEFORE any deployment
/// is applied (<c>_lastComposition</c> still null) is ignored — nothing is materialised on the publish
/// side (the equipment can't be resolved without a composition).</summary>
[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<string> { "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));
}
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
/// are materialised.</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
materialise.Variables.Count.ShouldBe(1);
materialise.Variables[0].DisplayName.ShouldBe("Model");
}
/// <summary>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.</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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);
}
/// <summary>Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a
/// redeploy. A second deployment re-runs <c>PushDesiredSubscriptions</c>, 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.</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// The redeploy fires a fresh RebuildAddressSpace first (drain it) ...
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<OpcUaPublishActor.AttributeValueUpdate>(Timeout);
update.NodeId.ShouldBe(fixedTreeNodeId);
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
}
/// <summary>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
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<ApplyAck>(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<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>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 <c>StartsWith(equipmentId + "/")</c> guard sees they no longer match EQ-2 and
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<ApplyAck>(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<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>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 <see cref="ITagDiscovery"/> child (policy
/// <see cref="DiscoveryRediscoverPolicy.Once"/>, 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 <c>ReconcileDrivers</c> does NOT restart the child — exactly the config-unchanged rebind). The
/// re-inject tail drops the stale EQ-1 entry and must send <see cref="DriverInstanceActor.TriggerRediscovery"/>
/// 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 <c>DiscoverCount</c> advances past the
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
/// under the new equipment EQ-2.</summary>
[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<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
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<ApplyAck>(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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger).
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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);
}
/// <summary>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 <c>droppedAny</c>
/// stays false and no <see cref="DriverInstanceActor.TriggerRediscovery"/> is sent. Uses the REAL
/// <see cref="ITagDiscovery"/> child (the existing <c>ExpectNoMsg</c> drop tests use a NON-discovery driver,
/// whose child would no-op a <c>TriggerRediscovery</c> in <c>StartDiscovery</c>'s <c>is not ITagDiscovery</c>
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
/// pass.</summary>
[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<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1).
publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises.
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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);
}
/// <summary>Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose
/// plan SURVIVES a redeploy must receive EXACTLY ONE <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>
/// for that pass — the authoreddiscovered 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 <c>SubscribeCount</c> (each non-empty SetDesiredSubscriptions ⇒ exactly one
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): 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".)</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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);
}
/// <summary>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 <c>_discoveredByDriver</c>)
/// must still receive EXACTLY ONE <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> — 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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — 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.</summary>
[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<OpcUaPublishActor.MaterialiseDiscoveredNodes>(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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(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);
}
/// <summary>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 <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> 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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
[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<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(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);
}
/// <summary>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 <c>_lastComposition</c> + 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 <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
/// that lands on the publish probe during apply is drained so the test's materialise / value-update
/// assertions see only post-apply traffic.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> 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<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
return (actor, publish, coordinator);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, plus a
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag so
/// a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned (mirrors
/// <c>DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags</c>).
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> 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;
}
/// <summary>
/// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the
/// <c>Equipment</c> row's <c>DriverInstanceId</c> (the <see cref="EquipmentNode.DriverInstanceId"/>
/// projection) but carries NO authored equipment tags — so the equipment can only be resolved from
/// <c>EquipmentNodes</c>, not <c>EquipmentTags</c>. A <c>DriverInstances</c> row (non-Windows-only
/// "Modbus", Enabled) is included so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is
/// spawned for the driver even though it has zero tags.
/// </summary>
private static DeploymentId SeedDeploymentWithTagLessEquipment(
IDbContextFactory<OtOpcUaConfigDbContext> 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<object>(),
});
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;
}
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
/// type, exposing its most-recent subscribed reference set for assertions (mirrors
/// <c>DriverHostActorWriteRoutingTests.RecordingDriverFactory</c>, but the driver is
/// <see cref="ISubscribable"/> so the merged subscription is observable).</summary>
private sealed class SubscribingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly SubscribableStubDriver _driver = new();
public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The reference set passed to the driver's most recent <c>SubscribeAsync</c> call.</summary>
public IReadOnlyList<string>? LastSubscribedRefs => _driver.LastSubscribedRefs;
/// <summary>Number of <c>SubscribeAsync</c> calls so far — lets a test prove a redundant re-apply did
/// NOT force a (drop-then-)re-subscribe of the whole handle.</summary>
public int SubscribeCount => _driver.SubscribeCount;
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Factory producing a single shared <see cref="DiscoverableSubscribingDriver"/> for the supported
/// type — a real (non-stubbed) <see cref="DriverInstanceActor"/> child that exposes <see cref="ITagDiscovery"/>,
/// so the host's post-connect discovery loop populates the discovered-node cache AND a
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> 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.</summary>
private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private DiscoverableSubscribingDriver? _driver;
public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>Number of <c>DiscoverAsync</c> passes the child has driven (advances on every
/// connect-time pass and every <see cref="DriverInstanceActor.TriggerRediscovery"/>-driven pass).</summary>
public int DiscoverCount => _driver?.DiscoverCount ?? 0;
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
string.Equals(driverType, _supportedType, StringComparison.Ordinal)
? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId)
: null;
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>A <see cref="StubDriver"/> that is BOTH <see cref="ISubscribable"/> (so the host's subscribe
/// path is exercised) and <see cref="ITagDiscovery"/> with policy <see cref="DiscoveryRediscoverPolicy.Once"/>
/// — exactly one post-connect pass, re-runnable by <see cref="DriverInstanceActor.TriggerRediscovery"/>. 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
/// <see cref="DiscoverCount"/> makes the trigger observable at the host level. Re-implements
/// <see cref="IDriver.DriverInstanceId"/> (the base <see cref="StubDriver"/> hardcodes "stub-driver-1") so the
/// spawned child reports the SPEC's id — otherwise its auto-sent <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
/// would carry the wrong driver id and resolve no equipment.</summary>
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;
/// <summary>The spec's driver instance id (re-mapped from the base "stub-driver-1").</summary>
public new string DriverInstanceId => _driverInstanceId;
/// <summary>Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery.</summary>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <summary>Number of <c>DiscoverAsync</c> passes driven so far.</summary>
public int DiscoverCount => Volatile.Read(ref _passCount);
/// <summary>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).</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange { add { } remove { } }
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
=> Task.FromResult<ISubscriptionHandle>(_handle);
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams an ever-growing FixedTree (pass N → N nodes).</summary>
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;
}
}
}