From a21cdfffbc8228465796ec7f0d450b5d7a26f603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 9 Apr 2026 21:51:41 +0200 Subject: [PATCH 1/3] fix: mocked `ITimer` does not wait for time to advance before invoking callback --- .../MockTimeSystem.cs | 2 +- .../TimeSystem/TimerFactoryMock.cs | 12 +-- .../TimeSystem/TimerMock.cs | 49 +++++++++-- .../TimeSystem/TimerMockTests.cs | 88 ++++++++++++++++++- 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs index 03b95455..9f5e9512 100644 --- a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs @@ -98,7 +98,7 @@ public MockTimeSystem(ITimeProviderFactory timeProvider, Func _timers = new(); private int _nextIndex = -1; - internal TimerFactoryMock(MockTimeSystem timeSystem) + internal TimerFactoryMock(MockTimeSystem timeSystem, bool autoAdvance) { _mockTimeSystem = timeSystem; + _autoAdvance = autoAdvance; _timerStrategy = TimerStrategy.Default; } @@ -38,7 +40,7 @@ public ITimeSystem TimeSystem public ITimer New(TimerCallback callback) { TimerMock timerMock = new(_mockTimeSystem, _timerStrategy, - callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan, _autoAdvance); return RegisterTimerMock(timerMock); } @@ -46,7 +48,7 @@ public ITimer New(TimerCallback callback) public ITimer New(TimerCallback callback, object? state, int dueTime, int period) { TimerMock timerMock = new(_mockTimeSystem, _timerStrategy, - callback, state, TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period)); + callback, state, TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period), _autoAdvance); return RegisterTimerMock(timerMock); } @@ -54,7 +56,7 @@ public ITimer New(TimerCallback callback, object? state, int dueTime, int period public ITimer New(TimerCallback callback, object? state, long dueTime, long period) { TimerMock timerMock = new(_mockTimeSystem, _timerStrategy, - callback, state, TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period)); + callback, state, TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period), _autoAdvance); return RegisterTimerMock(timerMock); } @@ -62,7 +64,7 @@ public ITimer New(TimerCallback callback, object? state, long dueTime, long peri public ITimer New(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) { TimerMock timerMock = new(_mockTimeSystem, _timerStrategy, - callback, state, dueTime, period); + callback, state, dueTime, period, _autoAdvance); return RegisterTimerMock(timerMock); } diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs index f5b63b0a..4cb37108 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs @@ -24,6 +24,7 @@ internal sealed class TimerMock : ITimerMock private readonly MockTimeSystem _mockTimeSystem; private Action? _onDispose; private TimeSpan _period; + private readonly bool _autoAdvance; private readonly object? _state; private readonly ITimerStrategy _timerStrategy; @@ -32,7 +33,8 @@ internal TimerMock(MockTimeSystem timeSystem, TimerCallback callback, object? state, TimeSpan dueTime, - TimeSpan period) + TimeSpan period, + bool autoAdvance) { if (dueTime.TotalMilliseconds < -1) { @@ -50,6 +52,7 @@ internal TimerMock(MockTimeSystem timeSystem, _state = state; _dueTime = dueTime; _period = period; + _autoAdvance = autoAdvance; if (_timerStrategy.Mode == TimerMode.StartImmediately) { Start(); @@ -224,16 +227,19 @@ internal void RegisterOnDispose(Action? onDispose) private async Task RunTimer(CancellationToken cancellationToken = default) { - await _mockTimeSystem.Task.Delay(_dueTime, cancellationToken).ConfigureAwait(false); + long nextPlannedExecution = _mockTimeSystem.TimeProvider.ElapsedTicks + _dueTime.Ticks; + if (_dueTime.TotalMilliseconds < 0) { cancellationToken.WaitHandle.WaitOne(_dueTime); } + else + { + await WaitUntil(nextPlannedExecution).ConfigureAwait(false); + } - long nextPlannedExecution = _mockTimeSystem.TimeProvider.ElapsedTicks; while (!cancellationToken.IsCancellationRequested) { - nextPlannedExecution += _period.Ticks; try { _callback(_state); @@ -260,12 +266,37 @@ private async Task RunTimer(CancellationToken cancellationToken = default) return; } - long nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; - if (nextPlannedExecution > nowTicks) + nextPlannedExecution += _period.Ticks; + await WaitUntil(nextPlannedExecution).ConfigureAwait(false); + } + + async Task WaitUntil(long targetTicks) + { + if (_autoAdvance) { - await _mockTimeSystem.Task - .Delay(TimeSpan.FromTicks(nextPlannedExecution - nowTicks), cancellationToken) - .ConfigureAwait(false); + long nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; + if (targetTicks > nowTicks) + { + await _mockTimeSystem.Task + .Delay(TimeSpan.FromTicks(targetTicks - nowTicks), + cancellationToken) + .ConfigureAwait(false); + } + } + else + { + long nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; + while (targetTicks > nowTicks) + { + long executeAfter = targetTicks; + using IAwaitableCallback onTimeChanged = _mockTimeSystem.On + .TimeChanged(predicate: _ + => _mockTimeSystem.TimeProvider.ElapsedTicks >= executeAfter); + await onTimeChanged.WaitAsync( + timeout: null, + cancellationToken: cancellationToken).ConfigureAwait(false); + nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; + } } } } diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs index c8bd02fb..79d332dd 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/TimerMockTests.cs @@ -48,6 +48,92 @@ public async Task Change_ValidPeriodValue_ShouldNotThrowException(int period) await That(exception).IsNull(); } + [Test] + public async Task DisableAutoAdvance_ShouldExecuteTimerLimitedNumberOfTimes() + { + int callbackCount = 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 callbackExecuted = new(0); + using ITimer timer = timeSystem.Timer.New(_ => + { + // ReSharper disable once AccessToModifiedClosure + Interlocked.Increment(ref callbackCount); + // ReSharper disable once AccessToDisposedClosure + callbackExecuted.Release(); + }, null, 1.Seconds(), 2.Seconds()); + + await Task.Delay(50.Milliseconds(), token); + await That(Volatile.Read(ref callbackCount)).IsEqualTo(0); + + // Advance past dueTime (1s): should trigger first callback + timeSystem.TimeProvider.AdvanceBy(2.Seconds()); + await callbackExecuted.WaitAsync(token); + await That(Volatile.Read(ref callbackCount)).IsEqualTo(1); + + // Advance past one period (2s): should trigger second callback + await Task.Delay(50.Milliseconds(), token); + timeSystem.TimeProvider.AdvanceBy(2.Seconds()); + await callbackExecuted.WaitAsync(token); + await That(Volatile.Read(ref callbackCount)).IsEqualTo(2); + + // Advance past two periods (4s): should trigger two more callbacks + await Task.Delay(50.Milliseconds(), token); + timeSystem.TimeProvider.AdvanceBy(4.Seconds()); + await callbackExecuted.WaitAsync(token); + await Task.Delay(50.Milliseconds(), token); + timeSystem.TimeProvider.AdvanceBy(0.Seconds()); + await callbackExecuted.WaitAsync(token); + await That(Volatile.Read(ref callbackCount)).IsEqualTo(4); + } + + [Test] + public async Task DisableAutoAdvance_ShouldNotExecuteTimerBeforeTimeElapsed() + { + int callbackCount = 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 ITimer timer = timeSystem.Timer.New(_ => + { + // ReSharper disable once AccessToModifiedClosure + Interlocked.Increment(ref callbackCount); + }, null, 1.Seconds(), 2.Seconds()); + + await Task.Delay(50.Milliseconds(), token); + await That(Volatile.Read(ref callbackCount)).IsEqualTo(0); + } + + [Test] + public async Task DisableAutoAdvance_ShouldStartTimerWhenTimeElapsed() + { + DateTime callbackTime = DateTime.MinValue; + 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 callbackExecuted = new(0); + using ITimer timer = timeSystem.Timer.New(_ => + { + callbackTime = timeSystem.DateTime.UtcNow; + // ReSharper disable once AccessToDisposedClosure + callbackExecuted.Release(); + }, null, 1.Seconds(), Timeout.InfiniteTimeSpan); + + await Task.Delay(50.Milliseconds(), token); + timeSystem.TimeProvider.AdvanceBy(2.Seconds()); + await callbackExecuted.WaitAsync(token); + DateTime after = timeSystem.DateTime.UtcNow; + + await That(callbackTime).IsEqualTo(after); + } + [Test] public async Task Dispose_ShouldDisposeTimer() { @@ -242,7 +328,7 @@ public async Task ShouldNotBeAffectedByTimeChange() ticks.Release(); advanced.Wait(token); // ReSharper restore AccessToDisposedClosure - }, null, TimeSpan.Zero, 2.Seconds()); + }, null, TimeSpan.Zero, 1.Seconds()); await Task.Delay(Timeout.InfiniteTimeSpan, token); } From fcd9636ae1042885a7cc8dc6eab1f6ae1e0db500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 9 Apr 2026 22:04:57 +0200 Subject: [PATCH 2/3] Fix review issue --- .../TimeSystem/TimerMock.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs index 4cb37108..815f7d68 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs @@ -285,17 +285,22 @@ await _mockTimeSystem.Task } else { - long nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; - while (targetTicks > nowTicks) + while (true) { long executeAfter = targetTicks; using IAwaitableCallback onTimeChanged = _mockTimeSystem.On .TimeChanged(predicate: _ => _mockTimeSystem.TimeProvider.ElapsedTicks >= executeAfter); + // Check AFTER registering the callback to avoid missing a time change + // that occurred between reading ElapsedTicks and subscribing. + if (_mockTimeSystem.TimeProvider.ElapsedTicks >= targetTicks) + { + break; + } + await onTimeChanged.WaitAsync( timeout: null, cancellationToken: cancellationToken).ConfigureAwait(false); - nowTicks = _mockTimeSystem.TimeProvider.ElapsedTicks; } } } From d431da5717a6999b800d2515a6a5bf9ed73552a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 10 Apr 2026 05:52:21 +0200 Subject: [PATCH 3/3] Ignore sonar issue --- Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs index 815f7d68..2df82983 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/TimerMock.cs @@ -225,6 +225,7 @@ internal void RegisterOnDispose(Action? onDispose) _onDispose = onDispose; } + #pragma warning disable S3776 // Cognitive Complexity of methods should not be too high private async Task RunTimer(CancellationToken cancellationToken = default) { long nextPlannedExecution = _mockTimeSystem.TimeProvider.ElapsedTicks + _dueTime.Ticks; @@ -305,6 +306,7 @@ await onTimeChanged.WaitAsync( } } } + #pragma warning restore S3776 // Cognitive Complexity of methods should not be too high private void Start() {