test(otopcua): assert exact discovered NodeId in the e2e

This commit is contained in:
Joseph Doherty
2026-06-26 09:04:26 -04:00
parent 5104540e32
commit 25ccd25b6b
@@ -74,6 +74,14 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
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(
@@ -112,27 +120,25 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
// 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, under EQ-1.
string fixedTreeNodeId = null!;
// (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.ShouldNotBeNull(); // EnsureVariable recorded for the FixedTree node
v.NodeId.ShouldStartWith("EQ-1"); // grafted UNDER the bound equipment root
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
fixedTreeNodeId = v.NodeId;
sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment
}, duration: Timeout);
// (b) A value published for the FixedTree ref routes to the mapped NodeId and lands Good — the live
// (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 == fixedTreeNodeId);
write.NodeId.ShouldBe(fixedTreeNodeId);
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);
@@ -159,22 +165,18 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
// First injection: capture the mapped NodeId once it has materialised on the sink.
string fixedTreeNodeId = null!;
AwaitAssert(() =>
{
var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
v.NodeId.ShouldNotBeNull();
fixedTreeNodeId = v.NodeId;
}, duration: Timeout);
// 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 == fixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
() => 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 == fixedTreeNodeId);
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.
@@ -183,17 +185,17 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
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 NodeId after the redeploy rebuild.
// (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild.
AwaitAssert(
() => sink.Variables.Count(x => x.NodeId == fixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
() => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
duration: Timeout);
// (b) A value published AFTER the redeploy STILL routes to the mapped NodeId and lands Good — the
// (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 == fixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
() => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
duration: Timeout);
}