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..bbaaaf08 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..06f3e9a1 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs @@ -0,0 +1,85 @@ +#if FEATURE_PERIODIC_TIMER +using System; +using System.Threading; +using System.Threading.Tasks; +using Testably.Abstractions.Testing.Helpers; +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 (cancellationToken.IsCancellationRequested) + { + throw ExceptionFactory.TaskWasCanceled(); + } + + if (_isDisposed) + { + return false; + } + + DateTime now = _timeSystem.DateTime.UtcNow; + DateTime nextTime = _lastTime + Period; + if (nextTime > now) + { + _timeSystem.TimeProvider.AdvanceBy(nextTime - now); + _lastTime = nextTime; + } + else + { + _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..8ed7b988 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/TimeSystem/PeriodicTimerTests.cs @@ -0,0 +1,148 @@ +#if FEATURE_PERIODIC_TIMER +using aweXpect.Chronology; +using System.Threading; +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(CancellationToken); + DateTime after = TimeSystem.DateTime.UtcNow; + + await That(result).IsTrue(); + await That(after - before).IsGreaterThanOrEqualTo(period).Within(50.Milliseconds()); + } + + [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(CancellationToken); + DateTime after1 = TimeSystem.DateTime.UtcNow; + await TimeSystem.Task.Delay(0.5.Seconds(), CancellationToken); + DateTime before2 = TimeSystem.DateTime.UtcNow; + await timer.WaitForNextTickAsync(CancellationToken); + DateTime after2 = TimeSystem.DateTime.UtcNow; + await TimeSystem.Task.Delay(2.Seconds(), CancellationToken); + DateTime before3 = TimeSystem.DateTime.UtcNow; + await timer.WaitForNextTickAsync(CancellationToken); + DateTime after3 = TimeSystem.DateTime.UtcNow; + + 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] + public async Task WaitForNextTickAsync_WhenDisposed_ShouldReturnFalse() + { + TimeSpan period = 200.Milliseconds(); + 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(); + } +} +#endif