diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs index 761c566a..4ff588db 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/HighReviewRegressionTests.cs @@ -25,7 +25,6 @@ public class HighReviewRegressionTests : TestKit private IActorRef Actor(IDataConnection adapter) => Sys.ActorOf(Props.Create(() => new DataConnectionActor( "conn", adapter, new DataConnectionOptions { ReconnectInterval = TimeSpan.FromMilliseconds(50), TagResolutionRetryInterval = TimeSpan.FromHours(1), SeedReadMaxAttempts = 1 }, _health, Substitute.For(), "OpcUa"))); - private void Connected() => AwaitCondition(() => _health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateConnectionHealth" && c.GetArguments()[1] is ConnectionHealth.Connected)); private static SubscribeTagsRequest Subscribe(string correlation = "s") => new(correlation, "instance", "conn", ["tag"], DateTimeOffset.UtcNow); private DataConnectionHealthReport Health(IActorRef actor) @@ -163,6 +162,17 @@ public class HighReviewRegressionTests : TestKit /// Verifies that writes while disconnected are rejected and never replayed after reconnect. /// Whether the connection is re-established after the rejected write. + /// + /// ScadaBridge#101: the writes used to be sent straight after ActorOf and their + /// replies awaited for a fixed 1 s, so in the reconnect: false case that window + /// also had to cover the actor's start and its first dispatch — a starved thread pool + /// in a full solution run could miss it. The test now waits on a barrier instead: the + /// connect attempt the writes race against has been ISSUED (the adapter's + /// ConnectAsync was entered) and is held open by gate, so the actor is + /// provably mid-attempt and cannot connect until the test releases it. The reply bound + /// is not part of the property: a write that was queued instead of rejected can only + /// be answered after the gate opens, which never happens before the bound expires. + /// [Theory] [InlineData(false)] [InlineData(true)] @@ -170,25 +180,35 @@ public class HighReviewRegressionTests : TestKit { var adapter = Adapter(); var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!reconnect) adapter.ConnectAsync(Arg.Any>(), Arg.Any()).Returns(gate.Task); + var attemptIssued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void HoldConnectAttempts() => adapter.ConnectAsync(Arg.Any>(), Arg.Any()) + .Returns(_ => { attemptIssued.TrySetResult(); return gate.Task; }); + if (!reconnect) HoldConnectAttempts(); var actor = Actor(adapter); if (reconnect) { - Connected(); - adapter.ConnectAsync(Arg.Any>(), Arg.Any()).Returns(gate.Task); + AwaitCondition(() => HealthReported(ConnectionHealth.Connected), BarrierTimeout); + HoldConnectAttempts(); adapter.Disconnected += Raise.Event(); - AwaitCondition(() => _health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateConnectionHealth" && c.GetArguments()[1] is ConnectionHealth.Disconnected)); + AwaitCondition(() => HealthReported(ConnectionHealth.Disconnected), BarrierTimeout); } + AwaitCondition(() => attemptIssued.Task.IsCompleted, BarrierTimeout); actor.Tell(new WriteTagRequest("single", "conn", "tag", 42, DateTimeOffset.UtcNow)); - ExpectMsg(r => !r.Success && r.CorrelationId == "single", TimeSpan.FromSeconds(1)); + ExpectMsg(r => !r.Success && r.CorrelationId == "single", BarrierTimeout); actor.Tell(new WriteTagBatchRequest("batch", "conn", new Dictionary { ["tag"] = 42 }, null, null, DateTimeOffset.UtcNow)); - ExpectMsg(r => !r.Success && r.CorrelationId == "batch", TimeSpan.FromSeconds(1)); + ExpectMsg(r => !r.Success && r.CorrelationId == "batch", BarrierTimeout); gate.SetResult(); actor.Tell(Subscribe()); - ExpectMsg(); + ExpectMsg(BarrierTimeout); Assert.DoesNotContain(adapter.ReceivedCalls(), c => c.GetMethodInfo().Name is "WriteAsync" or "WriteBatchAsync"); } + // Upper bound for barrier waits; never a property under test (see ScadaBridge#101). + private static readonly TimeSpan BarrierTimeout = TimeSpan.FromSeconds(10); + + private bool HealthReported(ConnectionHealth state) => _health.ReceivedCalls().Any(c => + c.GetMethodInfo().Name == "UpdateConnectionHealth" && c.GetArguments()[1] is ConnectionHealth h && h == state); + /// Verifies that a subscriber terminating releases the handle of its last tag. [Fact] public void SubscriberTermination_ReleasesLastTagHandle()