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:
-44
@@ -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);
|
||||
}
|
||||
}
|
||||
-353
@@ -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") { }
|
||||
}
|
||||
}
|
||||
-1327
File diff suppressed because it is too large
Load Diff
-583
@@ -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 ~0–2s 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 1–2 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()
|
||||
|
||||
Reference in New Issue
Block a user