diff --git a/Source/Mockolate/Verify/IAsyncVerificationResult.cs b/Source/Mockolate/Verify/IAsyncVerificationResult.cs index 9412eafc..9380a965 100644 --- a/Source/Mockolate/Verify/IAsyncVerificationResult.cs +++ b/Source/Mockolate/Verify/IAsyncVerificationResult.cs @@ -1,5 +1,7 @@ using System; +using System.Threading; using System.Threading.Tasks; +using Mockolate.Exceptions; using Mockolate.Interactions; namespace Mockolate.Verify; @@ -15,4 +17,21 @@ public interface IAsyncVerificationResult : IVerificationResult /// interactions, or until the timeout or cancellation token is triggered. /// Task VerifyAsync(Func predicate); + + /// + /// Asynchronously waits until the specified holds true for the current set of + /// interactions, or until the configured timeout or cancellation token, or the given + /// is triggered. + /// + /// + /// The only applies to this call and leaves the configured timeout and + /// cancellation token unchanged. + /// + /// + /// Thrown when the configured timeout or cancellation token is triggered first. + /// + /// + /// Thrown when the is canceled. + /// + Task VerifyAsync(Func predicate, CancellationToken cancellationToken); } diff --git a/Source/Mockolate/Verify/VerificationResult.cs b/Source/Mockolate/Verify/VerificationResult.cs index 283f4f9d..9253186a 100644 --- a/Source/Mockolate/Verify/VerificationResult.cs +++ b/Source/Mockolate/Verify/VerificationResult.cs @@ -255,7 +255,11 @@ bool IVerificationResult.Verify(Func predicate) } /// - public async Task VerifyAsync(Func predicate) + public Task VerifyAsync(Func predicate) + => VerifyAsync(predicate, CancellationToken.None); + + /// + public async Task VerifyAsync(Func predicate, CancellationToken cancellationToken) { ThrowIfRecordingDisabled(_interactions); IInteraction[] matchingInteractions = CollectMatching(); @@ -268,26 +272,7 @@ public async Task VerifyAsync(Func predicate) try { - CancellationTokenSource? cts = null; - CancellationToken token; - if (_timeout is null) - { - token = _cancellationToken!.Value; - } - else - { - if (_cancellationToken is not null) - { - cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken.Value); - } - else - { - cts = new CancellationTokenSource(); - } - - cts.CancelAfter(_timeout.Value); - token = cts.Token; - } + CancellationToken token = CreateWaitToken(cancellationToken, out CancellationTokenSource? cts); SemaphoreSlim semaphore = new(0); try @@ -328,6 +313,12 @@ void OnInteractionAdded(object? sender, EventArgs eventArgs) } catch (OperationCanceledException ex) { + if (cancellationToken.IsCancellationRequested) + { + // The wait was canceled on the linked token, but the caller expects its own token + throw new OperationCanceledException(ex.Message, ex, cancellationToken); + } + if (_cancellationToken?.IsCancellationRequested == true) { throw new MockVerificationTimeoutException(null, ex); @@ -337,6 +328,24 @@ void OnInteractionAdded(object? sender, EventArgs eventArgs) } } + private CancellationToken CreateWaitToken(CancellationToken cancellationToken, out CancellationTokenSource? cts) + { + if (_timeout is null && !cancellationToken.CanBeCanceled) + { + cts = null; + return _cancellationToken!.Value; + } + + cts = CancellationTokenSource.CreateLinkedTokenSource( + _cancellationToken ?? CancellationToken.None, cancellationToken); + if (_timeout is not null) + { + cts.CancelAfter(_timeout.Value); + } + + return cts.Token; + } + /// bool IFastVerifyCountResult.VerifyCount(Func countPredicate) { @@ -368,26 +377,7 @@ private async Task VerifyCountAsync(Func countPredicate) { try { - CancellationTokenSource? cts = null; - CancellationToken token; - if (_timeout is null) - { - token = _cancellationToken!.Value; - } - else - { - if (_cancellationToken is not null) - { - cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken.Value); - } - else - { - cts = new CancellationTokenSource(); - } - - cts.CancelAfter(_timeout.Value); - token = cts.Token; - } + CancellationToken token = CreateWaitToken(CancellationToken.None, out CancellationTokenSource? cts); SemaphoreSlim semaphore = new(0); try diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt index e2be3c6a..9b1c0253 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt @@ -3279,6 +3279,7 @@ namespace Mockolate.Verify public interface IAsyncVerificationResult : Mockolate.Verify.IVerificationResult { System.Threading.Tasks.Task VerifyAsync(System.Func predicate); + System.Threading.Tasks.Task VerifyAsync(System.Func predicate, System.Threading.CancellationToken cancellationToken); } public interface IMockVerify : Mockolate.IInteractiveMock { } public interface IVerificationResult diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt index 4c90f210..d0fbade9 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt @@ -2833,6 +2833,7 @@ namespace Mockolate.Verify public interface IAsyncVerificationResult : Mockolate.Verify.IVerificationResult { System.Threading.Tasks.Task VerifyAsync(System.Func predicate); + System.Threading.Tasks.Task VerifyAsync(System.Func predicate, System.Threading.CancellationToken cancellationToken); } public interface IMockVerify : Mockolate.IInteractiveMock { } public interface IVerificationResult diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt index 43834c52..b49283fe 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_netstandard2.0.txt @@ -2756,6 +2756,7 @@ namespace Mockolate.Verify public interface IAsyncVerificationResult : Mockolate.Verify.IVerificationResult { System.Threading.Tasks.Task VerifyAsync(System.Func predicate); + System.Threading.Tasks.Task VerifyAsync(System.Func predicate, System.Threading.CancellationToken cancellationToken); } public interface IMockVerify : Mockolate.IInteractiveMock { } public interface IVerificationResult diff --git a/Tests/Mockolate.Tests/Verify/VerificationResultTests.AsyncTests.cs b/Tests/Mockolate.Tests/Verify/VerificationResultTests.AsyncTests.cs index d58b897d..ec3ed131 100644 --- a/Tests/Mockolate.Tests/Verify/VerificationResultTests.AsyncTests.cs +++ b/Tests/Mockolate.Tests/Verify/VerificationResultTests.AsyncTests.cs @@ -96,7 +96,7 @@ public async Task VerifyAsync_WhenAlreadySuccessful_ShouldReturnTrue() VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) .Within(500.Milliseconds()); - await That(((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0)).IsTrue(); + await That(((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, CancellationToken.None)).IsTrue(); } [Fact] @@ -122,11 +122,98 @@ public async Task VerifyAsync_WhenMultipleIterationsAreNecessary_ShouldStopWhenS } }, token); - await That(((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 20)).IsTrue(); + await That(((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 20, CancellationToken.None)).IsTrue(); cts.Cancel(); await backgroundTask; } + [Fact] + public async Task VerifyAsync_WithCancellationToken_ShouldKeepConfiguredTimeout() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + using CancellationTokenSource cts = new(30.Seconds()); + + VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) + .Within(50.Milliseconds()); + + Task Act() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, cts.Token); + + await That(Act).Throws() + .Whose(e => e.Timeout, t => t.IsEqualTo(50.Milliseconds())); + } + + [Fact] + public async Task VerifyAsync_WithCancellationToken_ShouldLeaveResultUnchanged() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + CancellationToken canceledToken = new(true); + + VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) + .Within(50.Milliseconds()); + + Task ActWithToken() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, canceledToken); + + Task ActWithoutToken() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, CancellationToken.None); + + await That(ActWithToken).Throws(); + await That(ActWithoutToken).Throws() + .Whose(e => e.Timeout, t => t.IsEqualTo(50.Milliseconds())); + } + + [Fact] + public async Task VerifyAsync_WithCancellationToken_ShouldStopWaitingWhenConfiguredTokenIsCanceled() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + using CancellationTokenSource ownCts = new(50.Milliseconds()); + using CancellationTokenSource cts = new(30.Seconds()); + + VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) + .WithCancellation(ownCts.Token); + + Task Act() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, cts.Token); + + await That(Act).Throws() + .Whose(e => e.Timeout, t => t.IsNull()); + } + + [Fact] + public async Task VerifyAsync_WithCancellationToken_WhenCanceled_ShouldThrowOperationCanceledException() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + using CancellationTokenSource cts = new(50.Milliseconds()); + CancellationToken token = cts.Token; + + VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) + .Within(30.Seconds()); + + Task Act() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, token); + + await That(Act).Throws() + .Whose(e => e.CancellationToken, t => t.IsEqualTo(token)); + } + + [Fact] + public async Task VerifyAsync_WithCancellationToken_WhenCanceledWithoutTimeout_ShouldThrowOperationCanceledException() + { + IChocolateDispenser sut = IChocolateDispenser.CreateMock(); + using CancellationTokenSource cts = new(50.Milliseconds()); + CancellationToken token = cts.Token; + + VerificationResult result = sut.Mock.Verify.Dispense(Match.AnyParameters()) + .WithCancellation(CancellationToken.None); + + Task Act() + => ((IAsyncVerificationResult)result).VerifyAsync(l => l.Length > 0, token); + + await That(Act).Throws() + .Whose(e => e.CancellationToken, t => t.IsEqualTo(token)); + } + [Fact] public async Task WithCancellation_ShouldReturnAsyncVerificationResult() {