test(otopcua): negative + convergence coverage for rebind re-trigger (follow-up C)

This commit is contained in:
Joseph Doherty
2026-06-26 14:18:01 -04:00
parent 533671487e
commit cde16063d9
@@ -45,6 +45,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
@@ -517,9 +518,81 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10));
// ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves
// d1 → EQ-2 against the new _lastComposition).
// d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as
// { EQ-2: plan-scoped-to-EQ-2 }.
publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
m => m.EquipmentRootNodeId == "EQ-2", Timeout);
var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass)
// Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger,
// and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now
// SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises
// (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no
// fresh discovery pass runs.
var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC,
(Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger).
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout)
.EquipmentRootNodeId.ShouldBe("EQ-2");
// No further publish traffic and NO further discovery pass — the re-trigger converged.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
factory.DiscoverCount.ShouldBe(discoverCountAfterRebind);
}
/// <summary>Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT
/// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan
/// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is
/// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so <c>droppedAny</c>
/// stays false and no <see cref="DriverInstanceActor.TriggerRediscovery"/> is sent. Uses the REAL
/// <see cref="ITagDiscovery"/> child (the existing <c>ExpectNoMsg</c> drop tests use a NON-discovery driver,
/// whose child would no-op a <c>TriggerRediscovery</c> in <c>StartDiscovery</c>'s <c>is not ITagDiscovery</c>
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
/// pass.</summary>
[Fact]
public void No_drop_redeploy_does_not_re_trigger_discovery()
{
var db = NewInMemoryDbFactory();
var factory = new DiscoverableSubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
driverFactory: factory,
localRoles: new HashSet<string> { "driver" },
opcUaPublishActor: publish.Ref,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1).
publish.FishForMessage<OpcUaPublishActor.MaterialiseDiscoveredNodes>(
m => m.EquipmentRootNodeId == "EQ-1", Timeout);
AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout);
// Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop).
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(Timeout);
// The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises.
publish.ExpectMsg<OpcUaPublishActor.MaterialiseDiscoveredNodes>(Timeout)
.EquipmentRootNodeId.ShouldBe("EQ-1");
// But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no
// further publish traffic arrives. DiscoverCount stays 1.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
factory.DiscoverCount.ShouldBe(1);
}
/// <summary>Spawns the host with the subscribing driver factory + a publish probe, dispatches the