refactor(drivers): delete the dead discovered-node injection path (§8.2, #507)

#507 was filed as "injection inert in v3, re-migrate onto the raw subtree".
Reading it, the retained code is not revivable: it resolves equipment from
EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally
empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null
DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set).
Removing the guard would have changed a log line and injected nothing. So it
is deleted rather than fixed, and #507 closes as superseded by /raw
browse-commit.

Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost,
ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver,
_discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper,
DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes,
OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of
CapturingAddressSpaceBuilder/DiscoveredNode.

The connect-time discovery loop goes too (StartDiscovery, RediscoverTick,
HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With
injection gone it had no consumer, and leaving it would either dead-letter or
keep browsing real devices up to ~15x per connect to drop the result.
ITagDiscovery itself stays — the /raw browse picker drives it through
Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor,
so the picker is unaffected.

deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real —
blocked on #507". They are not: they assert an equipment-rooted graft
(EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never
have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the
CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests
in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped:
31 -> 13.

IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses()
has no production call site and nothing writes a DriverHostStatus row, but the
table was re-created in the v3 initial migration, so deleting on inference
would be wrong. Split to Gitea #521 with both options costed.

CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described
the seam as dead.

Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362
passed / 4 skipped.
This commit is contained in:
Joseph Doherty
2026-07-27 18:57:09 -04:00
parent 09a401b881
commit adce27e7fa
22 changed files with 117 additions and 4116 deletions
@@ -1,44 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class CapturingAddressSpaceBuilderTests
{
[Fact]
public void Records_nested_path_segments_full_reference_and_metadata()
{
var b = new CapturingAddressSpaceBuilder();
var focas = b.Folder("FOCAS", "FOCAS");
var device = focas.Folder("10.0.0.5:8193", "cnc");
var identity = device.Folder("Identity", "Identity");
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
b.Nodes.Count.ShouldBe(1);
var n = b.Nodes[0];
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
n.BrowseName.ShouldBe("SeriesNumber");
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
n.DataType.ShouldBe(DriverDataType.String);
n.Writable.ShouldBeFalse(); // ViewOnly -> read-only
}
[Fact]
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
{
var b = new CapturingAddressSpaceBuilder();
var f = b.Folder("FOCAS", "FOCAS");
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
SecurityClassification.ViewOnly, false, IsAlarm: true));
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
b.Nodes.Count.ShouldBe(1);
}
}
@@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
public const string DiscoveryInjectionDormantV3 =
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
"re-migrating injection onto the raw device subtree is a separate follow-up.";
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
// (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped.
// Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a
// re-browse prompt instead (DriverInstanceActorRediscoverySignalTests).
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =
@@ -1,119 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
[Trait("Category", "Unit")]
public sealed class DiscoveredNodeMapperTests
{
private static DiscoveredNode Node(string[] path, string name, string fullRef,
DriverDataType dt = DriverDataType.Float64, bool writable = false)
=> new(path, name, name, fullRef, dt, false, null, writable, false);
[Fact]
public void Maps_under_equipment_collapsing_single_device_folder()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
{
"EQ-1/FOCAS/Identity/SeriesNumber",
"EQ-1/FOCAS/Axes/X/AbsolutePosition",
}, ignoreOrder: true);
result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
}
[Fact]
public void Dedups_authored_refs()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"),
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string> { "parts-count" });
result.Variables.ShouldHaveSingleItem();
result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
}
[Fact]
public void Does_not_collapse_when_two_devices_present()
{
var nodes = new[]
{
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
};
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
{
"EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
"EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
}, ignoreOrder: true);
}
[Fact]
public void Empty_input_yields_empty_plan()
{
var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty<DiscoveredNode>(), authoredRefs: new HashSet<string>());
result.Folders.ShouldBeEmpty();
result.Variables.ShouldBeEmpty();
result.RoutingByRef.ShouldBeEmpty();
}
[Fact]
public void Array_metadata_passes_through_unchanged()
{
var node = new DiscoveredNode(
FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"],
BrowseName: "Positions",
DisplayName: "Positions",
FullReference: "10.0.0.5:8193/Axes/Positions",
DataType: DriverDataType.Float64,
IsArray: true,
ArrayDim: 8u,
Writable: false,
IsHistorized: false);
var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet<string>());
result.Variables.ShouldHaveSingleItem();
result.Variables[0].IsArray.ShouldBeTrue();
result.Variables[0].ArrayLength.ShouldBe(8u);
}
[Theory]
// Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float",
// Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through
// members must keep their enum name so the node manager resolves them to the matching built-in type.
[InlineData(DriverDataType.Float64, "Double")]
[InlineData(DriverDataType.Float32, "Float")]
[InlineData(DriverDataType.Reference, "String")]
[InlineData(DriverDataType.Boolean, "Boolean")]
[InlineData(DriverDataType.Int16, "Int16")]
[InlineData(DriverDataType.Int32, "Int32")]
[InlineData(DriverDataType.Int64, "Int64")]
[InlineData(DriverDataType.UInt16, "UInt16")]
[InlineData(DriverDataType.UInt32, "UInt32")]
[InlineData(DriverDataType.UInt64, "UInt64")]
[InlineData(DriverDataType.String, "String")]
[InlineData(DriverDataType.DateTime, "DateTime")]
public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected)
{
var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) };
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
result.Variables.ShouldHaveSingleItem();
result.Variables[0].DataType.ShouldBe(expected);
}
}
@@ -1,353 +0,0 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
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.OpcUaServer;
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>
/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the
/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites
/// (which wire the OPC UA publish side as a <see cref="Akka.TestKit.TestProbe"/> and assert on the
/// intercepted <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> /
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> messages), this suite wires the FULL real chain:
///
/// <list type="bullet">
/// <item>a real <see cref="DriverHostActor"/> (resolves equipment, maps via
/// <see cref="DiscoveredNodeMapper"/>, extends the live-value routing map, caches the plan);</item>
/// <item>a real <see cref="OpcUaPublishActor"/> as its <c>opcUaPublishActor</c> seam (so
/// <c>MaterialiseDiscoveredNodes</c> + <c>AttributeValueUpdate</c> are actually handled, not
/// intercepted);</item>
/// <item>a real <see cref="AddressSpaceApplier"/> over a recording
/// <see cref="IOpcUaAddressSpaceSink"/> (so the materialise + value-write reach the sink).</item>
/// </list>
///
/// <para>
/// The assertions are therefore made on the SINK's recorded <c>EnsureVariable</c> /
/// <c>RaiseNodesAddedModelChange</c> / <c>WriteValue</c> calls — i.e. the discovered node was
/// materialised through the real applier AND a published value surfaces <see cref="OpcUaQuality.Good"/>
/// at the mapped NodeId (in production this overwrites the <c>BadWaitingForInitialData</c> seed that
/// <c>OtOpcUaNodeManager.EnsureVariable</c> stamps on a freshly-materialised variable; the recording
/// sink does not model that seed, so the faithful assertion available here is that the live value
/// lands Good at the same NodeId the materialise created).
/// </para>
///
/// <para>
/// <b>Seam choices (faithful to the sibling suites).</b> Discovery is driven by Telling the host
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> directly, and the polled value by Telling
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> directly — exactly the seams the Task-7/8
/// tests use (there is no test seam to drive a real <see cref="ITagDiscovery"/> poll loop through a
/// child to Connected, and the spawned child is a <see cref="SubscribableStubDriver"/>). The publish
/// actor is wired WITHOUT a dbFactory, so the host's apply-time <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
/// falls back to a raw <c>sink.RebuildAddressSpace()</c> (no <c>EnsureVariable</c>); this keeps the
/// ONLY <c>EnsureVariable</c> traffic on the sink the discovered-node materialise itself, so the
/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing
/// map) is fully real.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node");
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);
// The FixedTree node the driver "discovers": FOCAS/<deviceHost>/Identity/SeriesNumber, a String value,
// whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored
// node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber.
private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber";
private const string FixedTreeDisplayName = "SeriesNumber";
// The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) +
// the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host
// segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse
// "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this
// EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the
// collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber").
private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber";
private static DiscoveredNode[] FixedTreeNodes() => new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
BrowseName: "SeriesNumber",
DisplayName: FixedTreeDisplayName,
FullReference: FixedTreeRef,
DataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
Writable: false,
IsHistorized: false),
};
/// <summary>
/// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows
/// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a
/// real publish actor + real applier + recording sink, then asserts:
/// (a) the sink recorded an <c>EnsureVariable</c> for the FixedTree node under EQ-1 (materialised
/// through the REAL applier — the node now exists in the served address space), with a
/// <c>RaiseNodesAddedModelChange</c> under EQ-1 so connected clients refresh;
/// (b) after an <see cref="DriverInstanceActor.AttributeValuePublished"/> for the FixedTree ref, the
/// sink recorded a <c>WriteValue</c> at THAT SAME NodeId carrying the value with
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
/// overwrote the BadWaitingForInitialData seed.
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId);
// Driver reports its captured FixedTree (the faithful Task-7/8 seam).
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
// (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT
// collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse).
AwaitAssert(() =>
{
var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1
v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink
v.Writable.ShouldBeFalse(); // discovered nodes are read-only
sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment
}, duration: Timeout);
// (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live
// value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue).
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts));
AwaitAssert(() =>
{
var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId);
write.NodeId.ShouldBe(ExpectedFixedTreeNodeId);
write.Value.ShouldBe("SN-12345");
write.Quality.ShouldBe(OpcUaQuality.Good);
write.Ts.ShouldBe(Ts);
}, duration: Timeout);
}
/// <summary>
/// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a
/// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new
/// revision, same d1 → EQ-1 binding) re-runs <c>PushDesiredSubscriptions</c> — which clears the
/// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts
/// the cached discovered plan. Asserts the sink records the FixedTree <c>EnsureVariable</c> AGAIN
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_and_value_survive_a_redeploy()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId);
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
// First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1.
AwaitAssert(
() => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName),
duration: Timeout);
// First value flows Good (pre-redeploy baseline).
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts));
AwaitAssert(
() => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
duration: Timeout);
var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId);
// Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions
// (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan.
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild.
AwaitAssert(
() => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
duration: Timeout);
// (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the
// live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it).
var tsAfter = Ts.AddSeconds(5);
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter));
AwaitAssert(
() => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
duration: Timeout);
}
/// <summary>Spawns the real chain — recording sink → real <see cref="AddressSpaceApplier"/> → real
/// <see cref="OpcUaPublishActor"/> (the host's <c>opcUaPublishActor</c> seam) → real
/// <see cref="DriverHostActor"/> backed by a <see cref="SubscribableStubDriver"/> — dispatches the
/// deployment, and waits for the Applied ACK so <c>_lastComposition</c> + the live child + the initial
/// subscribe pass have completed before discovery is injected. The publish actor is wired with the
/// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable)
/// and the only EnsureVariable traffic is the discovered-node materialise itself.</summary>
private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var vtHost = CreateTestProbe();
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
var host = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: new SubscribingDriverFactory("Modbus"),
localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish,
virtualTagHostOverride: vtHost.Ref));
host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return (host, sink, coordinator);
}
/// <summary>Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project
/// equipment tags + a real (non-stubbed) <see cref="DriverInstanceActor"/> child for each driver
/// (mirrors <c>DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags</c>). An authored value tag
/// both sets <c>_lastComposition</c> and binds the driver → equipment (the only way the host resolves the
/// equipment a discovered node grafts under).</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>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
/// type, so a real (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the driver and
/// the host's subscribe path is exercised (mirrors
/// <c>DriverHostActorDiscoveryTests.SubscribingDriverFactory</c>).</summary>
private sealed class SubscribingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly SubscribableStubDriver _driver = new();
public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <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>Recording <see cref="IOpcUaAddressSpaceSink"/> — captures the EnsureFolder / EnsureVariable /
/// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can
/// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the
/// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread).</summary>
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new();
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new();
private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new();
private readonly ConcurrentQueue<string> _modelChanges = new();
/// <summary>Gets a snapshot of the recorded EnsureFolder calls.</summary>
public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList();
/// <summary>Gets a snapshot of the recorded EnsureVariable calls.</summary>
public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList();
/// <summary>Gets a snapshot of the recorded WriteValue calls.</summary>
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList();
/// <summary>Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements.</summary>
public List<string> ModelChanges => _modelChanges.ToList();
/// <summary>Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback).</summary>
public int RebuildCalls;
/// <summary>Records a live-value write.</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Records an EnsureFolder call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records an EnsureVariable call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Records a NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -1,583 +0,0 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers the bounded post-connect re-discovery loop: when an <see cref="ITagDiscovery"/> driver
/// reaches Connected, <see cref="DriverInstanceActor"/> runs repeated discovery passes (FOCAS-style:
/// the FixedTree is suppressed until the driver's cache populates ~02s after connect) and ships each
/// pass's captured nodes to its parent as <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The
/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not
/// spin forever. A driver that does not implement <see cref="ITagDiscovery"/> produces no passes at all.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
{
/// <summary>
/// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third
/// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty
/// set repeats. The final <see cref="DriverInstanceActor.DiscoveredNodesReady"/> carries the 3 nodes
/// and no further passes arrive — proving the loop is bounded.
/// </summary>
[Fact]
public void Discovery_retries_until_set_stabilises_then_stops()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
// Drive Connecting → Connected; the Connected entry kicks discovery.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4
// (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops.
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
for (var i = 0; i < 4; i++)
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
// The loop must STOP once the non-empty set has stabilised — no fifth pass.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// Early passes were empty (FixedTree cache still populating).
msgs[0].Nodes.Count.ShouldBe(0);
msgs[1].Nodes.Count.ShouldBe(0);
// The set then appears and stabilises at 3 nodes.
msgs[2].Nodes.Count.ShouldBe(3);
var final = msgs[^1];
final.Nodes.Count.ShouldBe(3);
final.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" });
// The driver was asked exactly as many times as messages published — no extra zombie pass.
driver.DiscoverCount.ShouldBe(4);
}
/// <summary>A driver that does not implement <see cref="ITagDiscovery"/> produces no discovery passes —
/// the Connected entry's discovery kick is a no-op, so the parent receives no
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.</summary>
[Fact]
public void Driver_without_ITagDiscovery_produces_no_discovery()
{
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// No discovery capability ⇒ never any DiscoveredNodesReady to the parent.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a
/// <see cref="DriverInstanceActor.ForceReconnect"/> drives the actor through Reconnecting and
/// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use),
/// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's
/// capabilities changed across the reconnect. The new init bumps the generation, so any
/// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled
/// here, so none are in flight).
/// </summary>
[Fact]
public void Discovery_reruns_after_reconnect()
{
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromMilliseconds(50),
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped.
for (var i = 0; i < 4; i++)
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesBeforeReconnect = driver.DiscoverCount; // 4
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
actor.Tell(new DriverInstanceActor.ForceReconnect());
// A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees
// the stable 3-node set immediately.
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
afterReconnect.Nodes.Count.ShouldBe(3);
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// The driver was discovered again — proves a fresh loop ran, not a replay of the old one.
driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect);
}
/// <summary>
/// Regression for the Critical: a driver whose <c>DiscoverAsync</c> completes ASYNCHRONOUSLY (off the
/// actor thread) must still ship <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The handler
/// touches <c>Context.Parent</c> + <c>Timers</c> AFTER awaiting discovery; if it awaited with
/// <c>ConfigureAwait(false)</c> the continuation would resume off the actor context and those calls
/// would throw <c>NotSupportedException("no active ActorContext")</c> — the handler would fault and no
/// message would arrive. Synchronous (<c>Task.CompletedTask</c>) stubs mask the bug; this one forces a
/// genuine off-context resume (modelled on <c>SubscribableStubDriver.UnsubscribeYields</c>).
/// </summary>
[Fact]
public void Async_completing_discovery_resumes_on_actor_context_and_publishes()
{
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets
// a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out.
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
published.Nodes.Count.ShouldBe(3);
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
}
/// <summary>
/// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the
/// REAL <see cref="CapabilityInvoker"/> — whose Polly pipeline is awaited with
/// <c>ConfigureAwait(false)</c> internally. This is the one path the pass-through
/// <c>NullDriverCapabilityInvoker</c> (used by the sibling test) cannot cover: it returns the
/// call-site task directly, so it never exercises the invoker's internal off-context await. If that
/// internal <c>ConfigureAwait(false)</c> leaked to the actor's own <c>await _invoker.ExecuteAsync(…)</c>,
/// the post-await <c>Context.Parent.Tell</c> in <c>HandleRediscoverAsync</c> would fault with
/// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives
/// the real resilience pipeline.
/// </summary>
[Fact]
public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context()
{
var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults
var invoker = new CapabilityInvoker(
pipelineBuilder,
driverInstanceId: "yielding-discoverable",
optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A },
driverType: "Stub");
var driver = new YieldingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(5));
published.Nodes.Count.ShouldBe(3);
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
}
/// <summary>
/// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING
/// (1,2,3,…) never repeats its signature, so the loop is stopped only by
/// <c>rediscoverMaxAttempts</c>. With a cap of 3, exactly 3 passes are published, then the stream stops.
/// </summary>
[Fact]
public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap()
{
var driver = new GrowingDiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
for (var i = 0; i < 3; i++)
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
// Cap reached — no fourth pass even though the set never stabilised.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
// The set genuinely kept growing across the capped passes (1,2,3 nodes).
msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 });
driver.DiscoverCount.ShouldBe(3);
}
/// <summary>
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
/// <see cref="DiscoveryRediscoverPolicy.Never"/> opts out of post-connect discovery entirely: the
/// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never
/// asked to discover and the parent receives no <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.
/// </summary>
[Fact]
public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing()
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Connect happened (the discovery decision is made on the Connected entry)...
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// ...but policy=Never ⇒ no discovery pass is ever run and nothing is published.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(0);
}
/// <summary>
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
/// <see cref="DiscoveryRediscoverPolicy.Once"/> runs EXACTLY one post-connect pass even when its
/// discovered set would keep growing forever — under <c>UntilStable</c> the never-repeating signature
/// would retry to the attempt cap. Exactly one <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
/// is published and no further <c>RediscoverTick</c> is scheduled.
/// </summary>
[Fact]
public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing()
{
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Exactly one pass is published (the first, growing set → 1 node)...
var only = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
only.Nodes.Count.ShouldBe(1);
only.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// ...and NO second tick is scheduled, even though the set would keep growing under UntilStable.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(1);
}
/// <summary>
/// <see cref="DiscoveryRediscoverPolicy.Once"/> means one pass PER (re)connect cycle — not one pass
/// ever. After the initial single pass settles, a <see cref="DriverInstanceActor.ForceReconnect"/>
/// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and
/// <c>StartDiscovery</c> re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt
/// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the
/// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves
/// <c>Once</c> is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path
/// the follow-on TriggerRediscovery task touches.
/// </summary>
[Fact]
public void Discovery_policy_Once_reruns_one_pass_on_reconnect()
{
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
// visibly more than the one pass Once must run per (re)connect.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromMilliseconds(50),
rediscoverInterval: TimeSpan.FromMilliseconds(20),
rediscoverMaxAttempts: 3));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more.
var first = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
first.Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
driver.DiscoverCount.ShouldBe(1);
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
actor.Tell(new DriverInstanceActor.ForceReconnect());
// Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap.
// The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes.
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
afterReconnect.Nodes.Count.ShouldBe(2);
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(2);
}
/// <summary>
/// The per-pass discovery timeout is injectable via <see cref="DriverInstanceActor.Props"/> so tests
/// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving).
/// Wiring is verified by constructing via <c>Props</c> with a custom value and confirming the actor starts
/// and begins discovery normally.
/// </summary>
[Fact]
public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value()
{
// The constant must exist and preserve the pre-refactor 30 s literal.
DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30));
// Props must accept the new optional parameter — no throw and actor starts normally.
var driver = new DiscoverableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
rediscoverInterval: TimeSpan.FromMilliseconds(20),
rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Actor starts and discovery publishes — confirms the custom timeout was wired without error.
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while Connected re-kicks the
/// post-connect discovery loop: after the initial discovery has settled, sending the message drives a
/// FRESH discovery pass — the driver's <c>DiscoverCount</c> advances and a new
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> is published. This is the message
/// <see cref="DriverHostActor"/> uses to re-run discovery after rebinding the driver to a new equipment.
/// </summary>
[Fact]
public void TriggerRediscovery_when_Connected_reruns_discovery()
{
// Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely
// one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles.
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesBeforeTrigger = driver.DiscoverCount; // 1
// Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes).
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
var afterTrigger = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
afterTrigger.Nodes.Count.ShouldBe(2);
afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
// Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1);
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> on a driver whose
/// <see cref="ITagDiscovery.RediscoverPolicy"/> is <see cref="DiscoveryRediscoverPolicy.Never"/> does
/// NOT re-discover: the handler calls <c>StartDiscovery</c>, which returns early for <c>Never</c>, so
/// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out.
/// </summary>
[Fact]
public void TriggerRediscovery_with_policy_Never_does_not_rediscover()
{
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return.
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.DiscoverCount.ShouldBe(0);
}
/// <summary>
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while NOT Connected is a clean silent
/// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor
/// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both <c>Connecting</c>
/// (before init completes) and <c>Reconnecting</c> (after a <see cref="DriverInstanceActor.ForceReconnect"/>,
/// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed.
/// </summary>
[Fact]
public void TriggerRediscovery_when_not_Connected_is_a_silent_noop()
{
// Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state);
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
Watch(actor);
// (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it
// is handled in that non-Connected state.
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
// No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor).
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
ExpectNoMsg(TimeSpan.FromMilliseconds(100));
driver.DiscoverCount.ShouldBe(0);
// Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
var passesAfterConnect = driver.DiscoverCount; // 1
// (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto
// reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both
// messages are processed in order, so the trigger is handled while Reconnecting.
actor.Tell(new DriverInstanceActor.ForceReconnect());
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated
driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting
}
/// <summary>
/// A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c>
/// pass is counted; passes 12 yield nothing (cache warming), passes 3+ yield a stable 3-node set —
/// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put.
/// </summary>
private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery
{
private int _passCount;
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
/// existing UntilStable tests are unaffected.</summary>
public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
=> RediscoverPolicy = policy;
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
public int DiscoverCount => Volatile.Read(ref _passCount);
/// <summary>Streams a growing-then-stable node set into the builder (0,0,3,3,…).</summary>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
var count = pass >= 3 ? 3 : 0;
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < count; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
}
/// <summary>
/// A discoverable driver whose <c>DiscoverAsync</c> genuinely SUSPENDS and resumes on a fresh
/// thread-pool thread that carries NO Akka actor cell — modelled on
/// <c>SubscribableStubDriver.UnsubscribeYields</c>. This forces the actor's <c>await DiscoverAsync(...)</c>
/// continuation to resume off-context unless the handler omits <c>ConfigureAwait(false)</c>, so it is a
/// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass.
/// </summary>
private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery
{
/// <summary>Suspends on a TCS completed from a background thread, then streams 3 nodes.</summary>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_ = Task.Run(() => tcs.SetResult(), cancellationToken);
await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell)
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < 3; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
}
}
/// <summary>
/// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the
/// full-reference signature differs every pass and the loop can only be bounded by the attempt cap.
/// </summary>
private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery
{
private int _passCount;
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
/// existing attempt-cap test is unaffected. With <see cref="DiscoveryRediscoverPolicy.Once"/> the
/// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying).</summary>
public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
=> RediscoverPolicy = policy;
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
public int DiscoverCount => Volatile.Read(ref _passCount);
/// <summary>Streams an ever-growing node set (pass N → N nodes).</summary>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
var fixedTree = builder.Folder("FixedTree", "FixedTree");
for (var i = 0; i < pass; i++)
{
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
FullName: $"m.fixed.v{i}",
DriverDataType: DriverDataType.Float64,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false));
}
return Task.CompletedTask;
}
}
}
@@ -120,35 +120,6 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
}
/// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the
/// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a
/// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path
/// end to end (mirrors the real-applier-over-recording-sink harness in
/// <c>OpcUaPublishActorRebuildTests</c>).</summary>
[Fact]
public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink()
{
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") };
var variables = new[]
{
new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double",
Writable: false, IsArray: false, ArrayLength: null),
};
actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
AwaitAssert(() =>
{
sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes"));
sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false));
sink.ModelChanges.ShouldContain("EQ-1");
}, duration: PresenceBudget);
}
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
[Fact]
public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()