refactor(otopcua): extract authored-only send helper + empty-authored dropped-path test (follow-up D)

This commit is contained in:
Joseph Doherty
2026-06-26 14:44:26 -04:00
parent 05c820795a
commit 51721df563
2 changed files with 71 additions and 10 deletions
@@ -773,6 +773,59 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1);
}
/// <summary>Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly
/// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via
/// <see cref="EquipmentNode.DriverInstanceId"/>) whose FixedTree plan is FULLY DROPPED by a rebind redeploy
/// receives an EMPTY authored-only fallback <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>, which
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
[Fact]
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
// d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags.
var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1");
var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory);
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
Writable: false, IsHistorized: false),
}));
// First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the
// discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }.
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout);
AwaitAssert(() =>
{
var refs = factory.LastSubscribedRefs;
refs.ShouldNotBeNull();
refs!.ShouldContain("ft-ref-1");
}, duration: Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
var countBeforeRedeploy = factory.SubscribeCount;
// Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT
// restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed
// from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes.
var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1");
actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// No re-materialise — the cached plan was dropped (not re-grafted).
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or
// spurious subscribe — the bug this guards against — would increment it.)
factory.SubscribeCount.ShouldBe(countBeforeRedeploy);
}
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the
/// deployment, and waits for the Applied ACK so the apply (and thus <c>_lastComposition</c> + the live
/// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A