diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs index e951bb60..65fc6e57 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmAckRoutingTests.cs @@ -86,8 +86,19 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice")); - // Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack. - AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); + // POSITIVE CONTROL, not a sleep (#501). `Acks` is empty at t=0, so any assertion made before the + // host has processed the message above passes without testing anything — and AwaitAssert returns on + // its FIRST success, so it spends none of its duration and is the wrong tool for an absence. + // + // Instead send a MAPPED ack afterwards. The host processes its mailbox in order and forwards to the + // child via Tell, and the child handles RouteAlarmAck with ReceiveAsync (which suspends its own + // mailbox), so by the time this one reaches the driver the unmapped one has provably already been + // handled. Whatever `Acks` holds then is the complete answer. + actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice")); + + AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout); + recorder.Acks.Count.ShouldBe(1, + "only the control ack may reach the driver — the unmapped condition NodeId must have been dropped"); } /// On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the @@ -110,10 +121,25 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest }, CorrelationId.NewId())); - actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice")); + actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "gated", "alice")); - // No ack reached the driver — the gate short-circuited before the inverse-map lookup. - AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); + // POSITIVE CONTROL (#501) — same reasoning as the unmapped-node test, with the role itself as the + // control: return the node to PRIMARY and re-send the SAME mapped ack. Mailbox ordering means that + // once this one reaches the driver, the Secondary-gated one has already been processed. The + // discriminator is the comment, so a leaked ack is visible rather than merged into the count. + actor.Tell(new RedundancyStateChanged( + new[] + { + new NodeRedundancyState(TestNode, RedundancyRole.Primary, + IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow), + }, + CorrelationId.NewId())); + actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice")); + + AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout); + recorder.Acks.ShouldNotContain(a => a.Comment == "gated", + "the Primary gate must have dropped the ack sent while this node was Secondary"); + recorder.Acks.Count.ShouldBe(1); } /// Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs index 5937cd3e..46c2987d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Health/PeerProbeSupervisorTests.cs @@ -78,7 +78,9 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase duration: PresenceBudget); } - /// Verifies a single-node snapshot (just the local node) spawns no children. + /// Verifies a single-node snapshot (just the local node) spawns no children — the local node + /// is never its own probe peer. Established against a following two-node snapshot as a positive + /// control, so the count of 1 (rather than 2) is what carries the "no local child" claim. [Fact] public void Single_node_snapshot_spawns_no_children() { @@ -87,7 +89,20 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); - AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), + // POSITIVE CONTROL (#501). This is an ABSENCE assertion, and ChildCount is 0 before the snapshot + // above has been processed at all — so asserting it directly passes at t=0 and proves nothing + // (AwaitAssert returns on its first success, spending none of its duration). Unlike the other + // ChildCount.ShouldBe(0) waits in this class, there is no preceding ShouldBe(1) to make it a + // transition. + // + // Follow with a snapshot that DOES spawn exactly one child. The supervisor processes its mailbox + // in order, so once the count reaches 1 the single-node snapshot has provably been handled — and + // had it spawned a child for the local node, the count would be 2. + sup.Tell(Snapshot( + State(Local, RedundancyRole.Primary), + State(Peer, RedundancyRole.Secondary))); + + AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: PresenceBudget); }