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..2df82983 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(); @@ -222,18 +225,22 @@ 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) { - 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,15 +267,46 @@ 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 + { + 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); + } } } } + #pragma warning restore S3776 // Cognitive Complexity of methods should not be too high private void Start() { 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); }