From 1a442c63125cefac4a2a9125f20d263db1777fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 6 Apr 2026 15:49:38 +0200 Subject: [PATCH 1/2] feat: use monotonic clock for `PeriodicTimer` and `Stopwatch` --- .../TimeSystem/ITimeProvider.cs | 5 ++ .../TimeSystem/PeriodicTimerMock.cs | 18 ++--- .../TimeSystem/StopwatchFactoryMock.cs | 2 +- .../TimeSystem/StopwatchMock.cs | 8 +- .../TimeSystem/TimeProviderMock.cs | 6 ++ .../TimeSystem/TimerMock.cs | 12 +-- .../Testably.Abstractions.Testing_net10.0.txt | 1 + .../Testably.Abstractions.Testing_net6.0.txt | 1 + .../Testably.Abstractions.Testing_net8.0.txt | 1 + .../Testably.Abstractions.Testing_net9.0.txt | 1 + ...ly.Abstractions.Testing_netstandard2.0.txt | 1 + ...ly.Abstractions.Testing_netstandard2.1.txt | 1 + .../TimeSystem/PeriodicTimerMockTests.cs | 76 +++++++++++++++++++ .../TimeSystem/TimerMockTests.cs | 69 ++++++++++++++++- 14 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs b/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs index 01c9de0c3..b65258c79 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs @@ -8,6 +8,11 @@ namespace Testably.Abstractions.Testing.TimeSystem; /// public interface ITimeProvider { + /// + /// The elapsed ticks since the . + /// + long ElapsedTicks { get; } + /// /// Gets or sets the /// diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs index 37cecd039..047077efb 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs @@ -9,10 +9,10 @@ namespace Testably.Abstractions.Testing.TimeSystem; internal sealed class PeriodicTimerMock : IPeriodicTimer { + private readonly bool _autoAdvance; private bool _isDisposed; - private DateTime _lastTime; + private long _lastTime; private readonly MockTimeSystem _timeSystem; - private readonly bool _autoAdvance; internal PeriodicTimerMock(MockTimeSystem timeSystem, TimeSpan period, bool autoAdvance) @@ -21,7 +21,7 @@ internal PeriodicTimerMock(MockTimeSystem timeSystem, _timeSystem = timeSystem; _autoAdvance = autoAdvance; - _lastTime = _timeSystem.DateTime.UtcNow; + _lastTime = _timeSystem.TimeProvider.ElapsedTicks; Period = period; } @@ -58,23 +58,23 @@ public void Dispose() return false; } - DateTime now = _timeSystem.DateTime.UtcNow; - DateTime nextTime = _lastTime + Period; + long now = _timeSystem.TimeProvider.ElapsedTicks; + long nextTime = _lastTime + Period.Ticks; if (nextTime > now) { if (_autoAdvance) { - _timeSystem.TimeProvider.AdvanceBy(nextTime - now); + _timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromTicks(nextTime - now)); _lastTime = nextTime; } else { - using var onTimeChanged = _timeSystem.On - .TimeChanged(predicate: t => t >= nextTime); + using IAwaitableCallback onTimeChanged = _timeSystem.On + .TimeChanged(predicate: _ => _timeSystem.TimeProvider.ElapsedTicks >= nextTime); await onTimeChanged.WaitAsync( timeout: Timeout.InfiniteTimeSpan, cancellationToken: cancellationToken).ConfigureAwait(false); - _lastTime = _timeSystem.DateTime.UtcNow; + _lastTime = _timeSystem.TimeProvider.ElapsedTicks; } } else diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchFactoryMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchFactoryMock.cs index 349b552a5..540b0bbe0 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchFactoryMock.cs @@ -42,7 +42,7 @@ public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) /// public long GetTimestamp() - => _mockTimeSystem.TimeProvider.Read().Ticks * _tickPeriod; + => _mockTimeSystem.TimeProvider.ElapsedTicks * _tickPeriod; /// public IStopwatch New() diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchMock.cs index 29909133d..cbc7ce039 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/StopwatchMock.cs @@ -7,7 +7,7 @@ internal sealed class StopwatchMock : IStopwatch { private long _elapsedTicks; private readonly MockTimeSystem _mockTimeSystem; - private DateTime? _start; + private long? _start; private readonly long _tickPeriod; internal StopwatchMock(MockTimeSystem timeSystem, long tickPeriod) @@ -36,7 +36,7 @@ public long ElapsedTicks // If the Stopwatch is running, add elapsed time since the Stopwatch is started last time. if (_start is not null) { - timeElapsed += (_mockTimeSystem.TimeProvider.Read() - _start.Value).Ticks + timeElapsed += (_mockTimeSystem.TimeProvider.ElapsedTicks - _start.Value) * _tickPeriod; } @@ -70,7 +70,7 @@ public void Start() { if (_start is null) { - _start = _mockTimeSystem.TimeProvider.Read(); + _start = _mockTimeSystem.TimeProvider.ElapsedTicks; } } @@ -79,7 +79,7 @@ public void Stop() { if (_start.HasValue) { - _elapsedTicks += (_mockTimeSystem.TimeProvider.Read() - _start.Value).Ticks * + _elapsedTicks += (_mockTimeSystem.TimeProvider.ElapsedTicks - _start.Value) * _tickPeriod; _start = null; } diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs index ab4cbb823..63d731005 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs @@ -21,6 +21,7 @@ public TimeProviderMock(Action onTimeChanged, DateTime now, string des _now = now.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(now, DateTimeKind.Utc) : now; + ElapsedTicks = _now.Ticks; StartTime = _now; _onTimeChanged = onTimeChanged; _description = description; @@ -46,12 +47,16 @@ public TimeProviderMock(Action onTimeChanged, DateTime now, string des /// public DateTime StartTime { get; } + /// + public long ElapsedTicks { get; private set; } + /// public void AdvanceBy(TimeSpan interval) { lock (_lock) { _now = _now.Add(interval); + ElapsedTicks += interval.Ticks; _onTimeChanged.Invoke(_now); } } @@ -68,6 +73,7 @@ public void SetTo(DateTime value) lock (_lock) { _now = value; + _onTimeChanged.Invoke(_now); } } diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs index c2ef90293..f5b63b0ae 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs @@ -230,10 +230,10 @@ private async Task RunTimer(CancellationToken cancellationToken = default) cancellationToken.WaitHandle.WaitOne(_dueTime); } - DateTime nextPlannedExecution = _mockTimeSystem.DateTime.UtcNow; + long nextPlannedExecution = _mockTimeSystem.TimeProvider.ElapsedTicks; while (!cancellationToken.IsCancellationRequested) { - nextPlannedExecution += _period; + nextPlannedExecution += _period.Ticks; try { _callback(_state); @@ -260,10 +260,12 @@ private async Task RunTimer(CancellationToken cancellationToken = default) return; } - TimeSpan delay = nextPlannedExecution - _mockTimeSystem.DateTime.UtcNow; - if (delay > TimeSpan.Zero) + long nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; + if (nextPlannedExecution > nowTicks) { - await _mockTimeSystem.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await _mockTimeSystem.Task + .Delay(TimeSpan.FromTicks(nextPlannedExecution - nowTicks), cancellationToken) + .ConfigureAwait(false); } } } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt index 6d5ab422a..8bb8efd3b 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt @@ -444,6 +444,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt index 6b458a720..88e89a87e 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt @@ -433,6 +433,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt index ceb66fed1..072ad37df 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt @@ -444,6 +444,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt index 1baf656e7..26650fd1c 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt @@ -444,6 +444,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt index b23518219..b039d0ac7 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt @@ -413,6 +413,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt index 4c6427cc4..674fde6cc 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt @@ -426,6 +426,7 @@ namespace Testably.Abstractions.Testing.TimeSystem } public interface ITimeProvider { + long ElapsedTicks { get; } System.DateTime MaxValue { get; set; } System.DateTime MinValue { get; set; } System.DateTime StartTime { get; } diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs new file mode 100644 index 000000000..726724c72 --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs @@ -0,0 +1,76 @@ +#if FEATURE_PERIODIC_TIMER +using aweXpect.Chronology; +using System.Threading; +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Testing.Tests.TimeSystem; + +public class PeriodicTimerMockTests +{ + [Test] + public async Task ShouldNotBeAffectedByTimeChange() + { + int timerCount = 0; + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using CancellationTokenSource cts = CancellationTokenSource + .CreateLinkedTokenSource(TestContext.Current!.Execution.CancellationToken); + cts.CancelAfter(30.Seconds()); + CancellationToken token = cts.Token; + using SemaphoreSlim ticks = new(0); + using SemaphoreSlim advanced = new(0); + Task timerTask = Task.Run(async () => + { + try + { + using IPeriodicTimer timer = timeSystem.PeriodicTimer.New(1.Seconds()); + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + while (await timer.WaitForNextTickAsync(token)) + { + // ReSharper disable once AccessToModifiedClosure + Interlocked.Increment(ref timerCount); + // ReSharper disable AccessToDisposedClosure + ticks.Release(); + await advanced.WaitAsync(token); + // ReSharper restore AccessToDisposedClosure + } + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + }, token); + + await ticks.WaitAsync(token); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + await That(Volatile.Read(ref timerCount)).IsEqualTo(3); + + // Changing the wall clock time should not affect the periodic timer + timeSystem.TimeProvider.SetTo(timeSystem.DateTime.Now - 10.Seconds()); + + for (int i = 0; i < 10; i++) + { + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + } + + await That(Volatile.Read(ref timerCount)).IsEqualTo(13); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + await That(Volatile.Read(ref timerCount)).IsEqualTo(14); + cts.Cancel(); + await timerTask; + } +} +#endif diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs index e015802e3..c8bd02fba 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs @@ -1,4 +1,5 @@ -using System.Threading; +using aweXpect.Chronology; +using System.Threading; using Testably.Abstractions.Testing.TimeSystem; using ITimer = Testably.Abstractions.TimeSystem.ITimer; @@ -218,6 +219,72 @@ public async Task New_WithStartOnMockWaitMode_ShouldOnlyStartWhenCallingWait() await That(count).IsGreaterThan(0); } + [Test] + public async Task ShouldNotBeAffectedByTimeChange() + { + int timerCount = 0; + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using CancellationTokenSource cts = CancellationTokenSource + .CreateLinkedTokenSource(TestContext.Current!.Execution.CancellationToken); + cts.CancelAfter(30.Seconds()); + CancellationToken token = cts.Token; + using SemaphoreSlim ticks = new(0); + using SemaphoreSlim advanced = new(0); + Task timerTask = Task.Run(async () => + { + try + { + using ITimer timer = timeSystem.Timer.New(_ => + { + // ReSharper disable once AccessToModifiedClosure + Interlocked.Increment(ref timerCount); + // ReSharper disable AccessToDisposedClosure + ticks.Release(); + advanced.Wait(token); + // ReSharper restore AccessToDisposedClosure + }, null, TimeSpan.Zero, 2.Seconds()); + + await Task.Delay(Timeout.InfiniteTimeSpan, token); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + }, token); + + await ticks.WaitAsync(token); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + await That(Volatile.Read(ref timerCount)).IsEqualTo(3); + + // Changing the wall clock time should not affect the timer + timeSystem.TimeProvider.SetTo(timeSystem.DateTime.Now + 10.Seconds()); + + for (int i = 0; i < 10; i++) + { + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + } + + await That(Volatile.Read(ref timerCount)).IsEqualTo(13); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + advanced.Release(); + await ticks.WaitAsync(token); + + await That(Volatile.Read(ref timerCount)).IsEqualTo(14); + cts.Cancel(); + await timerTask; + } + [Test] public async Task Wait_Infinite_ShouldBeValidTimeout() { From b0703b2c9e51b2a1fd2bea727b7c71fe0252115c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 6 Apr 2026 16:34:22 +0200 Subject: [PATCH 2/2] Fix review issues --- .../TimeSystem/ITimeProvider.cs | 16 ++++++++++++++- .../TimeSystem/TimeProviderMock.cs | 20 ++++++++++++++----- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs b/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs index b65258c79..763eed76d 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/ITimeProvider.cs @@ -8,9 +8,23 @@ namespace Testably.Abstractions.Testing.TimeSystem; /// public interface ITimeProvider { +#if FEATURE_PERIODIC_TIMER /// - /// The elapsed ticks since the . + /// The elapsed ticks represent a monotonic clock that is not affected by changes to the system time. /// + /// + /// It is used internally for the , the and the .
+ /// The value is not affected by changes to the system time (see ), but it is affected by the method.
+ ///
+#else + /// + /// The elapsed ticks represent a monotonic clock that is not affected by changes to the system time. + /// + /// + /// It is used internally for the and the .
+ /// The value is not affected by changes to the system time (see ), but it is affected by the method.
+ ///
+#endif long ElapsedTicks { get; } /// diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs index 63d731005..09d3158ce 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimeProviderMock.cs @@ -1,4 +1,4 @@ -using System; +using System; #if NETSTANDARD2_0 using Testably.Abstractions.TimeSystem; #endif @@ -8,6 +8,7 @@ namespace Testably.Abstractions.Testing.TimeSystem; internal sealed class TimeProviderMock : ITimeProvider { private DateTime _now; + private long _elapsedTicks; private readonly Action _onTimeChanged; private readonly string _description; #if NET9_0_OR_GREATER @@ -21,7 +22,7 @@ public TimeProviderMock(Action onTimeChanged, DateTime now, string des _now = now.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(now, DateTimeKind.Utc) : now; - ElapsedTicks = _now.Ticks; + _elapsedTicks = _now.Ticks; StartTime = _now; _onTimeChanged = onTimeChanged; _description = description; @@ -48,7 +49,13 @@ public TimeProviderMock(Action onTimeChanged, DateTime now, string des public DateTime StartTime { get; } /// - public long ElapsedTicks { get; private set; } + public long ElapsedTicks + { + get + { + lock (_lock) { return _elapsedTicks; } + } + } /// public void AdvanceBy(TimeSpan interval) @@ -56,7 +63,7 @@ public void AdvanceBy(TimeSpan interval) lock (_lock) { _now = _now.Add(interval); - ElapsedTicks += interval.Ticks; + _elapsedTicks += interval.Ticks; _onTimeChanged.Invoke(_now); } } @@ -64,7 +71,10 @@ public void AdvanceBy(TimeSpan interval) /// public DateTime Read() { - return _now; + lock (_lock) + { + return _now; + } } ///