From cde16063d98b8d24b84214a9a8527e67777f0dc4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 14:18:01 -0400 Subject: [PATCH] test(otopcua): negative + convergence coverage for rebind re-trigger (follow-up C) --- .../Drivers/DriverHostActorDiscoveryTests.cs | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs index f26dfa25..c96c19c4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -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( 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(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.ExpectMsg(Timeout); + // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger). + publish.ExpectMsg(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); + } + + /// 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 droppedAny + /// stays false and no is sent. Uses the REAL + /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver, + /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery + /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The + /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once + /// pass. + [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 { "driver" }, + opcUaPublishActor: publish.Ref, + virtualTagHostOverride: vtHost.Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.ExpectMsg(Timeout); + + // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1). + publish.FishForMessage( + 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(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); + publish.ExpectMsg(Timeout); + // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises. + publish.ExpectMsg(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); } /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the