diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TaskMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TaskMock.cs index 92e94ad2..180fb54a 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TaskMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TaskMock.cs @@ -59,11 +59,51 @@ public Task Delay(TimeSpan delay, CancellationToken cancellationToken) if (_autoAdvance) { _mockTimeSystem.TimeProvider.AdvanceBy(delay); + _callbackHandler.InvokeTaskDelayCallbacks(delay); + return Task.CompletedTask; } _callbackHandler.InvokeTaskDelayCallbacks(delay); - return Task.CompletedTask; + + if (delay == TimeSpan.Zero) + { + return Task.CompletedTask; + } + + return DelayUntilAdvanced(delay, cancellationToken); } #endregion + + /// + /// Returns a task that stays pending until the simulated clock advances to or past + /// now + , or faults with an + /// if the is + /// cancelled while pending. + /// + /// A never completes on its own and can only be + /// released via cancellation. + /// + private async Task DelayUntilAdvanced(TimeSpan delay, CancellationToken cancellationToken) + { + bool isInfinite = delay.TotalMilliseconds < 0; + long targetTicks = _mockTimeSystem.TimeProvider.ElapsedTicks + delay.Ticks; + + while (true) + { + using IAwaitableCallback onTimeChanged = _mockTimeSystem.On + .TimeChanged(predicate: _ + => !isInfinite && + _mockTimeSystem.TimeProvider.ElapsedTicks >= targetTicks); + if (!isInfinite && + _mockTimeSystem.TimeProvider.ElapsedTicks >= targetTicks) + { + return; + } + + await onTimeChanged.WaitAsync( + timeout: null, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } } diff --git a/Tests/Helpers/Testably.Abstractions.TestHelpers/TimeSystemTestsAttribute.cs b/Tests/Helpers/Testably.Abstractions.TestHelpers/TimeSystemTestsAttribute.cs index 71a45e3b..3861c62a 100644 --- a/Tests/Helpers/Testably.Abstractions.TestHelpers/TimeSystemTestsAttribute.cs +++ b/Tests/Helpers/Testably.Abstractions.TestHelpers/TimeSystemTestsAttribute.cs @@ -7,7 +7,8 @@ namespace Testably.Abstractions.TestHelpers; [AttributeUsage(AttributeTargets.Class)] -public class TimeSystemTestsAttribute : TypedDataSourceAttribute +public class TimeSystemTestsAttribute(bool disableAutoAdvance = false) + : TypedDataSourceAttribute { public override async IAsyncEnumerable>> GetTypedDataRowsAsync( DataGeneratorMetadata dataGeneratorMetadata) @@ -17,7 +18,15 @@ public override async IAsyncEnumerable>> GetTypedD { DateTime now = DateTime.UtcNow; return Task.FromResult( - new TimeSystemTestData(now, new MockTimeSystem(TimeProvider.Use(now)))); + new TimeSystemTestData(now, new MockTimeSystem(TimeProvider.Use(now), o => + { + if (disableAutoAdvance) + { + o.DisableAutoAdvance(); + } + + return o; + }))); }; yield return () => Task.FromResult( new TimeSystemTestData(DateTime.UtcNow, new RealTimeSystem())); diff --git a/Tests/Testably.Abstractions.Testing.Tests/MockTimeSystemTests.cs b/Tests/Testably.Abstractions.Testing.Tests/MockTimeSystemTests.cs index 33a20408..61f224ea 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/MockTimeSystemTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/MockTimeSystemTests.cs @@ -9,15 +9,113 @@ namespace Testably.Abstractions.Testing.Tests; public class MockTimeSystemTests { [Test] - public async Task Delay_DisabledAutoAdvance_ShouldNotChangeTime() + public async Task + Delay_DisabledAutoAdvance_InfiniteTimeSpan_ShouldOnlyBeReleasedByCancellation() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using CancellationTokenSource cts = CancellationTokenSource + .CreateLinkedTokenSource(TestContext.Current!.Execution.CancellationToken); + + Task delayTask = timeSystem.Task.Delay(Timeout.InfiniteTimeSpan, cts.Token); + + // Advancing the clock never releases an infinite delay. + timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromDays(1)); + await That(delayTask.IsCompleted).IsFalse(); + + cts.Cancel(); + + async Task Act() => await delayTask; + await That(Act).Throws(); + } + + [Test] + public async Task + Delay_DisabledAutoAdvance_MultiplePendingDelays_ShouldEachBeReleasedAtTheirDueTime() { MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + CancellationToken token = TestContext.Current!.Execution.CancellationToken; + + Task delay1 = timeSystem.Task.Delay(1.Seconds(), token); + Task delay2 = timeSystem.Task.Delay(2.Seconds(), token); + Task delay3 = timeSystem.Task.Delay(3.Seconds(), token); + + timeSystem.TimeProvider.AdvanceBy(2.Seconds()); + + await delay1; + await delay2; + await That(delay1.IsCompleted).IsTrue(); + await That(delay2.IsCompleted).IsTrue(); + await That(delay3.IsCompleted).IsFalse(); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + + await delay3; + await That(delay3.IsCompleted).IsTrue(); + } + + [Test] + public async Task Delay_DisabledAutoAdvance_PartialAdvance_ShouldNotReleaseDelay() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + CancellationToken token = TestContext.Current!.Execution.CancellationToken; + + Task delayTask = timeSystem.Task.Delay(5.Seconds(), token); + + timeSystem.TimeProvider.AdvanceBy(3.Seconds()); + await That(delayTask.IsCompleted).IsFalse(); + + timeSystem.TimeProvider.AdvanceBy(2.Seconds()); + await delayTask; + await That(delayTask.IsCompleted).IsTrue(); + } + + [Test] + public async Task Delay_DisabledAutoAdvance_SetTo_ShouldNotReleaseDelay_OnlyAdvanceByDoes() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + CancellationToken token = TestContext.Current!.Execution.CancellationToken; + + Task delayTask = timeSystem.Task.Delay(5.Seconds(), token); + + // Jumping the wall clock (e.g. an NTP/DST change) must NOT release a monotonic delay. + timeSystem.TimeProvider.SetTo(timeSystem.DateTime.UtcNow.AddHours(1)); + await That(delayTask.IsCompleted).IsFalse(); + + // Only elapsed (monotonic) time releases it. + timeSystem.TimeProvider.AdvanceBy(5.Seconds()); + await delayTask; + await That(delayTask.IsCompleted).IsTrue(); + } + + [Test] + public async Task Delay_DisabledAutoAdvance_ShouldStayPendingUntilTimeIsAdvanced() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + CancellationToken token = TestContext.Current!.Execution.CancellationToken; DateTime before = timeSystem.DateTime.Now; - await timeSystem.Task.Delay(5.Seconds(), TestContext.Current!.Execution.CancellationToken); + Task delayTask = timeSystem.Task.Delay(5.Seconds(), token); - DateTime after = timeSystem.DateTime.Now; - await That(after).IsEqualTo(before); + // The delay stays pending and does not advance time on its own. + await That(delayTask.IsCompleted).IsFalse(); + await That(timeSystem.DateTime.Now).IsEqualTo(before); + + timeSystem.TimeProvider.AdvanceBy(5.Seconds()); + + await delayTask; + await That(delayTask.IsCompleted).IsTrue(); + } + + [Test] + public async Task Delay_DisabledAutoAdvance_Zero_ShouldCompleteImmediately() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + CancellationToken token = TestContext.Current!.Execution.CancellationToken; + + Task delayTask = timeSystem.Task.Delay(TimeSpan.Zero, token); + + await delayTask; + await That(delayTask.IsCompleted).IsTrue(); } [Test] @@ -114,9 +212,10 @@ public async Task PeriodicTimer_DisabledAutoAdvance_ShouldNotAdvanceTime() #if FEATURE_PERIODIC_TIMER [Test] [Arguments(5)] - public async Task PeriodicTimer_DisabledAutoAdvance_ShouldTriggerWhenTimeIsManuallyAdvanced(int amount) + public async Task PeriodicTimer_DisabledAutoAdvance_ShouldTriggerWhenTimeIsManuallyAdvanced( + int amount) { - var time = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime time = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); MockTimeSystem timeSystem = new(time, o => o.DisableAutoAdvance()); List tickTimes = []; using CancellationTokenSource cts = CancellationTokenSource diff --git a/Tests/Testably.Abstractions.Tests/TimeSystem/TaskDelayCancellationTests.cs b/Tests/Testably.Abstractions.Tests/TimeSystem/TaskDelayCancellationTests.cs new file mode 100644 index 00000000..380665a7 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/TimeSystem/TaskDelayCancellationTests.cs @@ -0,0 +1,44 @@ +using System.Threading; + +namespace Testably.Abstractions.Tests.TimeSystem; + +[TimeSystemTests(true)] +public class TaskDelayCancellationTests(TimeSystemTestData testData) : TimeSystemTestBase(testData) +{ + [Test] + public async Task Delay_AlreadyCancelledToken_ShouldThrowOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + CancellationToken token = cts.Token; + + async Task Act() + => await TimeSystem.Task.Delay(TimeSpan.FromSeconds(30), token); + + await That(Act).Throws(); + } + + [Test] + public async Task Delay_CancelledWhilePending_ShouldThrowOperationCanceledException() + { + using CancellationTokenSource cts = CancellationTokenSource + .CreateLinkedTokenSource(TestContext.Current!.Execution.CancellationToken); + Task delayTask = TimeSystem.Task.Delay(TimeSpan.FromSeconds(30), cts.Token); + + cts.Cancel(); + + async Task Act() => await delayTask; + + await That(Act).Throws(); + } + + [Test] + public async Task Delay_Zero_ShouldCompleteSuccessfully() + { + Exception? exception = await Record.ExceptionAsync(() + => TimeSystem.Task.Delay(TimeSpan.Zero, + TestContext.Current!.Execution.CancellationToken)); + + await That(exception).IsNull(); + } +}