From 9d534c190f4038878d8956448d878ae3e84c23b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 13 Mar 2026 17:55:43 +0100 Subject: [PATCH 1/2] feat: implement mock `PeriodicTimer` in testing package --- Directory.Packages.props | 5 +- Pipeline/Build.cs | 2 +- README.md | 1 + .../Helpers/ExceptionFactory.cs | 16 ++- .../MockTimeSystem.cs | 12 ++ .../TimeSystem/PeriodicTimerFactoryMock.cs | 33 ++++++ .../TimeSystem/PeriodicTimerMock.cs | 75 ++++++++++++ .../Testably.Abstractions.Testing_net10.0.txt | 1 + .../Testably.Abstractions.Testing_net8.0.txt | 1 + .../Testably.Abstractions.Testing_net9.0.txt | 1 + Tests/Directory.Build.props | 1 + .../PeriodicTimerFactoryMockTests.cs | 24 ++++ .../TimeSystem/PeriodicTimerFactoryTests.cs | 47 ++++++++ .../TimeSystem/PeriodicTimerTests.cs | 111 ++++++++++++++++++ 14 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs create mode 100644 Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs create mode 100644 Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerFactoryMockTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerFactoryTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 72f9af80..db8125a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,7 @@ + @@ -49,8 +50,8 @@ - - + + diff --git a/Pipeline/Build.cs b/Pipeline/Build.cs index f5452a74..65bdc27a 100644 --- a/Pipeline/Build.cs +++ b/Pipeline/Build.cs @@ -20,7 +20,7 @@ partial class Build : NukeBuild /// /// Afterward, you can update the package reference in `Directory.Packages.props` and reset this flag. /// - readonly BuildScope BuildScope = BuildScope.CoreOnly; + readonly BuildScope BuildScope = BuildScope.Default; [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; diff --git a/README.md b/README.md index 5a420fa3..2b2e90ac 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ In addition, the following interfaces are defined: - `Stopwatch` is a wrapper around [`System.Diagnostics.Stopwatch`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch) - `Task` allows replacing [`Task.Delay`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay) - `Thread` allows replacing [`Thread.Sleep`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.sleep) + - `PeriodicTimer` is a wrapper around [`System.Threading.PeriodicTimer`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.periodictimer) - `Timer` is a wrapper around [`System.Threading.Timer`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.timer) - The `IRandomSystem` interface abstracts away functionality related to randomness: `Random` methods implement a thread-safe Shared instance also under .NET Framework and `Guid` methods allow creating new GUIDs. diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 7ffcccef..aff74e35 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -94,7 +94,8 @@ internal static IOException FileSharingViolation() }; internal static IOException FileSharingViolation(string path) - => new($"The process cannot access the file '{path}' because it is being used by another process.") + => new( + $"The process cannot access the file '{path}' because it is being used by another process.") { #if FEATURE_EXCEPTION_HRESULT HResult = -2147024864, @@ -156,6 +157,11 @@ internal static NotSupportedException NotSupportedSafeFileHandle() internal static NotSupportedException NotSupportedStopwatchWrapping() => new("You cannot wrap an existing Stopwatch in the MockTimeSystem instance!"); +#if FEATURE_PERIODIC_TIMER + internal static NotSupportedException NotSupportedPeriodicTimerWrapping() + => new("You cannot wrap an existing PeriodicTimer in the MockTimeSystem instance!"); +#endif + internal static NotSupportedException NotSupportedTimerWrapping() => new("You cannot wrap an existing Timer in the MockTimeSystem instance!"); @@ -229,7 +235,8 @@ internal static IOException ProcessCannotAccessTheFile(string path, int hResult) internal static IOException ReplaceSourceMustBeDifferentThanDestination( string sourcePath, string destinationPath) - => new($"The source '{sourcePath}' and destination '{destinationPath}' are the same file.", -2146232800); + => new($"The source '{sourcePath}' and destination '{destinationPath}' are the same file.", + -2146232800); #pragma warning disable MA0015 // Specify the parameter name internal static ArgumentException SearchPatternCannotContainTwoDots() @@ -334,7 +341,8 @@ internal static PlatformNotSupportedException UnixFileModeNotSupportedOnThisPlat HResult = -2146233031, #endif }; - + internal static ArgumentException InvalidUnixCreateMode(string paramName) - => new("UnixCreateMode can only be used with file modes that can create a new file.", paramName); + => new("UnixCreateMode can only be used with file modes that can create a new file.", + paramName); } diff --git a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs index 85715efa..397b4d9c 100644 --- a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs @@ -32,6 +32,9 @@ public INotificationHandler On private readonly StopwatchFactoryMock _stopwatchFactoryMock; private readonly TaskMock _taskMock; private readonly ThreadMock _threadMock; +#if FEATURE_PERIODIC_TIMER + private readonly PeriodicTimerFactoryMock _periodictimerFactoryMock; +#endif private readonly TimerFactoryMock _timerFactoryMock; /// @@ -59,6 +62,9 @@ public MockTimeSystem(ITimeProvider timeProvider) _stopwatchFactoryMock = new StopwatchFactoryMock(this); _threadMock = new ThreadMock(this, _callbackHandler); _taskMock = new TaskMock(this, _callbackHandler); +#if FEATURE_PERIODIC_TIMER + _periodictimerFactoryMock = new PeriodicTimerFactoryMock(this); +#endif _timerFactoryMock = new TimerFactoryMock(this); } @@ -68,6 +74,12 @@ public MockTimeSystem(ITimeProvider timeProvider) public IDateTime DateTime => _dateTimeMock; +#if FEATURE_PERIODIC_TIMER + /// + public IPeriodicTimerFactory PeriodicTimer + => _periodictimerFactoryMock; +#endif + /// public IStopwatchFactory Stopwatch => _stopwatchFactoryMock; diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs new file mode 100644 index 00000000..cacd40ea --- /dev/null +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs @@ -0,0 +1,33 @@ +#if FEATURE_PERIODIC_TIMER +using System; +using System.Threading; +using Testably.Abstractions.Testing.Helpers; +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Testing.TimeSystem; + +internal sealed class PeriodicTimerFactoryMock : IPeriodicTimerFactory +{ + private readonly MockTimeSystem _mockTimeSystem; + + internal PeriodicTimerFactoryMock(MockTimeSystem timeSystem) + { + _mockTimeSystem = timeSystem; + } + + #region IPeriodicTimerFactory Members + + /// + public ITimeSystem TimeSystem => _mockTimeSystem; + + /// + public IPeriodicTimer New(TimeSpan period) + => new PeriodicTimerMock(_mockTimeSystem, period); + + /// + public IPeriodicTimer Wrap(PeriodicTimer timer) + => throw ExceptionFactory.NotSupportedPeriodicTimerWrapping(); + + #endregion +} +#endif diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs new file mode 100644 index 00000000..eda1bcc4 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs @@ -0,0 +1,75 @@ +#if FEATURE_PERIODIC_TIMER +using System; +using System.Threading; +using System.Threading.Tasks; +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Testing.TimeSystem; + +internal sealed class PeriodicTimerMock : IPeriodicTimer +{ + private bool _isDisposed; + private DateTime _lastTime; + private readonly MockTimeSystem _timeSystem; + + internal PeriodicTimerMock(MockTimeSystem timeSystem, + TimeSpan period) + { + ThrowIfPeriodIsInvalid(period, nameof(period)); + + _timeSystem = timeSystem; + _lastTime = _timeSystem.DateTime.UtcNow; + Period = period; + } + + #region IPeriodicTimer Members + + /// + public TimeSpan Period + { + get; + set + { + ThrowIfPeriodIsInvalid(value, nameof(value)); + field = value; + } + } + + /// + public ITimeSystem TimeSystem => _timeSystem; + + /// + public void Dispose() + => _isDisposed = true; + + /// + public async ValueTask WaitForNextTickAsync(CancellationToken cancellationToken = new()) + { + if (_isDisposed) + { + return false; + } + + DateTime now = _timeSystem.DateTime.UtcNow; + DateTime nextTime = _lastTime + Period; + if (nextTime > now) + { + _timeSystem.TimeProvider.AdvanceBy(nextTime - now); + } + + _lastTime = now; + await Task.Yield(); + return true; + } + + #endregion + + private static void ThrowIfPeriodIsInvalid(TimeSpan period, string paramName) + { + if (period.TotalMilliseconds < 1 && period != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(paramName); + } + } +} +#endif 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 921b4f81..b9419455 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 @@ -105,6 +105,7 @@ namespace Testably.Abstractions.Testing public MockTimeSystem(Testably.Abstractions.Testing.TimeSystem.ITimeProvider timeProvider) { } public Testably.Abstractions.TimeSystem.IDateTime DateTime { get; } public Testably.Abstractions.Testing.TimeSystem.INotificationHandler On { get; } + public Testably.Abstractions.TimeSystem.IPeriodicTimerFactory PeriodicTimer { get; } public Testably.Abstractions.TimeSystem.IStopwatchFactory Stopwatch { get; } public Testably.Abstractions.TimeSystem.ITask Task { get; } public Testably.Abstractions.TimeSystem.IThread Thread { 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 7ade5f59..c2836131 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 @@ -105,6 +105,7 @@ namespace Testably.Abstractions.Testing public MockTimeSystem(Testably.Abstractions.Testing.TimeSystem.ITimeProvider timeProvider) { } public Testably.Abstractions.TimeSystem.IDateTime DateTime { get; } public Testably.Abstractions.Testing.TimeSystem.INotificationHandler On { get; } + public Testably.Abstractions.TimeSystem.IPeriodicTimerFactory PeriodicTimer { get; } public Testably.Abstractions.TimeSystem.IStopwatchFactory Stopwatch { get; } public Testably.Abstractions.TimeSystem.ITask Task { get; } public Testably.Abstractions.TimeSystem.IThread Thread { 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 ff084f74..e8354002 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 @@ -105,6 +105,7 @@ namespace Testably.Abstractions.Testing public MockTimeSystem(Testably.Abstractions.Testing.TimeSystem.ITimeProvider timeProvider) { } public Testably.Abstractions.TimeSystem.IDateTime DateTime { get; } public Testably.Abstractions.Testing.TimeSystem.INotificationHandler On { get; } + public Testably.Abstractions.TimeSystem.IPeriodicTimerFactory PeriodicTimer { get; } public Testably.Abstractions.TimeSystem.IStopwatchFactory Stopwatch { get; } public Testably.Abstractions.TimeSystem.ITask Task { get; } public Testably.Abstractions.TimeSystem.IThread Thread { get; } diff --git a/Tests/Directory.Build.props b/Tests/Directory.Build.props index 2cea7909..c0583a64 100644 --- a/Tests/Directory.Build.props +++ b/Tests/Directory.Build.props @@ -27,6 +27,7 @@ + diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerFactoryMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerFactoryMockTests.cs new file mode 100644 index 00000000..226721ea --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerFactoryMockTests.cs @@ -0,0 +1,24 @@ +#if FEATURE_PERIODIC_TIMER +using System.Threading; + +namespace Testably.Abstractions.Testing.Tests.TimeSystem; + +public class PeriodicTimerFactoryMockTests +{ + [Test] + public async Task Wrap_ShouldThrowNotSupportedException() + { + MockTimeSystem timeSystem = new(); + using PeriodicTimer periodicTimer = new(TimeSpan.FromSeconds(5)); + + void Act() + { + // ReSharper disable once AccessToDisposedClosure + _ = timeSystem.PeriodicTimer.Wrap(periodicTimer); + } + + await That(Act).Throws().WithMessage( + "You cannot wrap an existing PeriodicTimer in the MockTimeSystem instance!"); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerFactoryTests.cs b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerFactoryTests.cs new file mode 100644 index 00000000..31338c8e --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerFactoryTests.cs @@ -0,0 +1,47 @@ +#if FEATURE_PERIODIC_TIMER +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Tests.TimeSystem; + +[TimeSystemTests] +public class PeriodicTimerFactoryTests(TimeSystemTestData testData) : TimeSystemTestBase(testData) +{ + [Test] + public async Task New_PeriodIsInfinite_ShouldNotThrow() + { + TimeSpan period = TimeSpan.FromMilliseconds(-1); + + void Act() + { + _ = TimeSystem.PeriodicTimer.New(period); + } + + await That(Act).DoesNotThrow(); + } + + [Test] + [Arguments(0)] + [Arguments(-2)] + public async Task New_PeriodIsZeroOrNegative_ShouldThrowArgumentOutOfRangeException( + int milliseconds) + { + TimeSpan period = TimeSpan.FromMilliseconds(milliseconds); + + void Act() + { + _ = TimeSystem.PeriodicTimer.New(period); + } + + await That(Act).Throws().WithParamName("period"); + } + + [Test] + public async Task New_ShouldCreatePeriodicTimerWithGivenPeriod() + { + TimeSpan period = TimeSpan.FromSeconds(1); + using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + + await That(timer.Period).IsEqualTo(period); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs new file mode 100644 index 00000000..a8eee3eb --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs @@ -0,0 +1,111 @@ +#if FEATURE_PERIODIC_TIMER +using aweXpect.Chronology; +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Tests.TimeSystem; + +[TimeSystemTests] +public class PeriodicTimerTests(TimeSystemTestData testData) : TimeSystemTestBase(testData) +{ + [Test] + public async Task Dispose_Twice_ShouldNotThrow() + { + TimeSpan period = 200.Milliseconds(); + IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + timer.Dispose(); + + void Act() + { + // ReSharper disable once AccessToDisposedClosure + timer.Dispose(); + } + + await That(Act).DoesNotThrow(); + } + + [Test] + public async Task SetPeriod_PeriodIsInfinite_ShouldNotThrow() + { + TimeSpan period = TimeSpan.FromMilliseconds(-1); + using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(5.Seconds()); + + void Act() + { + // ReSharper disable once AccessToDisposedClosure + timer.Period = period; + } + + await That(Act).DoesNotThrow(); + } + + [Test] + [Arguments(0)] + [Arguments(-2)] + public async Task SetPeriod_PeriodIsZeroOrNegative_ShouldThrowArgumentOutOfRangeException( + int milliseconds) + { + TimeSpan period = TimeSpan.FromMilliseconds(milliseconds); + using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(5.Seconds()); + + void Act() + { + // ReSharper disable once AccessToDisposedClosure + timer.Period = period; + } + + await That(Act).Throws().WithParamName("value"); + } + + [Test] + public async Task WaitForNextTickAsync_ShouldWaitForPeriodOnFirstCall() + { + TimeSpan period = 200.Milliseconds(); + using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + + DateTime before = TimeSystem.DateTime.UtcNow; + bool result = await timer.WaitForNextTickAsync(); + DateTime after = TimeSystem.DateTime.UtcNow; + + await That(result).IsTrue(); + await That(after - before).IsGreaterThanOrEqualTo(period); + } + + [Test] + public async Task + WaitForNextTickAsync_WhenCallbackTakesLongerThanPeriod_ShouldContinueWithNewTime() + { + Skip.If(TimeSystem is RealTimeSystem); + + TimeSpan period = 1.Seconds(); + using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + + DateTime before1 = TimeSystem.DateTime.UtcNow; + await timer.WaitForNextTickAsync(); + DateTime after1 = TimeSystem.DateTime.UtcNow; + await TimeSystem.Task.Delay(2.Seconds()); + DateTime before2 = TimeSystem.DateTime.UtcNow; + await timer.WaitForNextTickAsync(); + DateTime after2 = TimeSystem.DateTime.UtcNow; + await TimeSystem.Task.Delay(2.Seconds()); + DateTime before3 = TimeSystem.DateTime.UtcNow; + await timer.WaitForNextTickAsync(); + DateTime after3 = TimeSystem.DateTime.UtcNow; + + await That(after1 - before1).IsGreaterThanOrEqualTo(500.Milliseconds()); + await That(after2 - before2).IsLessThan(500.Milliseconds()); + await That(after3 - before3).IsLessThan(500.Milliseconds()); + } + + [Test] + public async Task WaitForNextTickAsync_WhenDisposed_ShouldReturnFalse() + { + TimeSpan period = 200.Milliseconds(); + IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + timer.Dispose(); + + bool result = await timer.WaitForNextTickAsync(); + + await That(result).IsFalse(); + } +} +#endif From f1804f6b23f02d3fb22b130a480328342f072fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 13 Mar 2026 20:01:45 +0100 Subject: [PATCH 2/2] Fix sonar issues and review comments --- .../MockTimeSystem.cs | 6 +- .../TimeSystem/PeriodicTimerMock.cs | 12 +++- .../TimeSystem/PeriodicTimerTests.cs | 57 +++++++++++++++---- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs index 397b4d9c..bbaaaf08 100644 --- a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs @@ -33,7 +33,7 @@ public INotificationHandler On private readonly TaskMock _taskMock; private readonly ThreadMock _threadMock; #if FEATURE_PERIODIC_TIMER - private readonly PeriodicTimerFactoryMock _periodictimerFactoryMock; + private readonly PeriodicTimerFactoryMock _periodicTimerFactoryMock; #endif private readonly TimerFactoryMock _timerFactoryMock; @@ -63,7 +63,7 @@ public MockTimeSystem(ITimeProvider timeProvider) _threadMock = new ThreadMock(this, _callbackHandler); _taskMock = new TaskMock(this, _callbackHandler); #if FEATURE_PERIODIC_TIMER - _periodictimerFactoryMock = new PeriodicTimerFactoryMock(this); + _periodicTimerFactoryMock = new PeriodicTimerFactoryMock(this); #endif _timerFactoryMock = new TimerFactoryMock(this); } @@ -77,7 +77,7 @@ public IDateTime DateTime #if FEATURE_PERIODIC_TIMER /// public IPeriodicTimerFactory PeriodicTimer - => _periodictimerFactoryMock; + => _periodicTimerFactoryMock; #endif /// diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs index eda1bcc4..06f3e9a1 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Testably.Abstractions.Testing.Helpers; using Testably.Abstractions.TimeSystem; namespace Testably.Abstractions.Testing.TimeSystem; @@ -45,6 +46,11 @@ public void Dispose() /// public async ValueTask WaitForNextTickAsync(CancellationToken cancellationToken = new()) { + if (cancellationToken.IsCancellationRequested) + { + throw ExceptionFactory.TaskWasCanceled(); + } + if (_isDisposed) { return false; @@ -55,9 +61,13 @@ public void Dispose() if (nextTime > now) { _timeSystem.TimeProvider.AdvanceBy(nextTime - now); + _lastTime = nextTime; + } + else + { + _lastTime = now; } - _lastTime = now; await Task.Yield(); return true; } diff --git a/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs index a8eee3eb..8ed7b988 100644 --- a/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs +++ b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs @@ -1,5 +1,6 @@ #if FEATURE_PERIODIC_TIMER using aweXpect.Chronology; +using System.Threading; using Testably.Abstractions.TimeSystem; namespace Testably.Abstractions.Tests.TimeSystem; @@ -63,11 +64,11 @@ public async Task WaitForNextTickAsync_ShouldWaitForPeriodOnFirstCall() using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); DateTime before = TimeSystem.DateTime.UtcNow; - bool result = await timer.WaitForNextTickAsync(); + bool result = await timer.WaitForNextTickAsync(CancellationToken); DateTime after = TimeSystem.DateTime.UtcNow; await That(result).IsTrue(); - await That(after - before).IsGreaterThanOrEqualTo(period); + await That(after - before).IsGreaterThanOrEqualTo(period).Within(50.Milliseconds()); } [Test] @@ -80,20 +81,54 @@ public async Task using IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); DateTime before1 = TimeSystem.DateTime.UtcNow; - await timer.WaitForNextTickAsync(); + await timer.WaitForNextTickAsync(CancellationToken); DateTime after1 = TimeSystem.DateTime.UtcNow; - await TimeSystem.Task.Delay(2.Seconds()); + await TimeSystem.Task.Delay(0.5.Seconds(), CancellationToken); DateTime before2 = TimeSystem.DateTime.UtcNow; - await timer.WaitForNextTickAsync(); + await timer.WaitForNextTickAsync(CancellationToken); DateTime after2 = TimeSystem.DateTime.UtcNow; - await TimeSystem.Task.Delay(2.Seconds()); + await TimeSystem.Task.Delay(2.Seconds(), CancellationToken); DateTime before3 = TimeSystem.DateTime.UtcNow; - await timer.WaitForNextTickAsync(); + await timer.WaitForNextTickAsync(CancellationToken); DateTime after3 = TimeSystem.DateTime.UtcNow; - await That(after1 - before1).IsGreaterThanOrEqualTo(500.Milliseconds()); - await That(after2 - before2).IsLessThan(500.Milliseconds()); - await That(after3 - before3).IsLessThan(500.Milliseconds()); + await That(after1 - before1).IsEqualTo(1.Seconds()).Within(100.Milliseconds()); + await That(after2 - before2).IsEqualTo(500.Milliseconds()).Within(100.Milliseconds()); + await That(after3 - before3).IsEqualTo(0.Milliseconds()).Within(100.Milliseconds()); + } + + [Test] + public async Task WaitForNextTickAsync_WhenCancelled_ShouldThrowTaskCanceledException() + { + TimeSpan period = 200.Milliseconds(); + IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + CancellationToken cancellationToken = new(true); + + async Task Act() + { + await timer.WaitForNextTickAsync(cancellationToken); + } + + await That(Act).Throws() + .WithMessage("A task was canceled."); + } + + [Test] + public async Task + WaitForNextTickAsync_WhenCancelledAndDisposed_ShouldThrowTaskCanceledException() + { + TimeSpan period = 200.Milliseconds(); + IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); + CancellationToken cancellationToken = new(true); + timer.Dispose(); + + async Task Act() + { + await timer.WaitForNextTickAsync(cancellationToken); + } + + await That(Act).Throws() + .WithMessage("A task was canceled."); } [Test] @@ -103,7 +138,9 @@ public async Task WaitForNextTickAsync_WhenDisposed_ShouldReturnFalse() IPeriodicTimer timer = TimeSystem.PeriodicTimer.New(period); timer.Dispose(); + #pragma warning disable MA0040 // Use an overload with a CancellationToken bool result = await timer.WaitForNextTickAsync(); + #pragma warning restore MA0040 await That(result).IsFalse(); }