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