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();
+ }
+}