diff --git a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs index d3f0d454..03b95455 100644 --- a/Source/Testably.Abstractions.Testing/MockTimeSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockTimeSystem.cs @@ -96,7 +96,7 @@ public MockTimeSystem(ITimeProviderFactory timeProvider, Func -/// The callback handler for the +/// The callback handler for the . /// public interface INotificationHandler { +#if FEATURE_PERIODIC_TIMER + /// + /// Notifications for the . + /// + IPeriodicTimerNotificationHandler PeriodicTimer { get; } +#endif + /// /// Callback executed when any of the following DateTime read methods is called:
/// -
diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/IPeriodicTimerNotificationHandler.cs b/Source/Testably.Abstractions.Testing/TimeSystem/IPeriodicTimerNotificationHandler.cs new file mode 100644 index 00000000..7166d183 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/TimeSystem/IPeriodicTimerNotificationHandler.cs @@ -0,0 +1,27 @@ +#if FEATURE_PERIODIC_TIMER +using System; +using Testably.Abstractions.TimeSystem; + +namespace Testably.Abstractions.Testing.TimeSystem; + +/// +/// The callback handler for the of the . +/// +public interface IPeriodicTimerNotificationHandler +{ + /// + /// Callback executed when any periodic timer is waiting for the next tick. + /// + /// + /// (optional) The callback to execute when the periodic timer is waiting for the next tick. The parameter is the periodic timer which is waiting for the next tick. + /// + /// + /// (optional) A predicate used to filter which callbacks should be notified.
+ /// If set to (default value) all callbacks are notified. + /// + /// A to un-register the callback on dispose. + IAwaitableCallback WaitingForNextTick( + Action? callback = null, + Func? predicate = null); +} +#endif diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/NotificationHandler.cs b/Source/Testably.Abstractions.Testing/TimeSystem/NotificationHandler.cs index f6dc985e..16e788bf 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/NotificationHandler.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/NotificationHandler.cs @@ -1,12 +1,25 @@ using System; +#if FEATURE_PERIODIC_TIMER +using Testably.Abstractions.TimeSystem; +#endif namespace Testably.Abstractions.Testing.TimeSystem; -internal sealed class NotificationHandler(MockTimeSystem mockTimeSystem) : INotificationHandler +internal sealed class NotificationHandler(MockTimeSystem mockTimeSystem) +#if FEATURE_PERIODIC_TIMER + : INotificationHandler, IPeriodicTimerNotificationHandler +#else + : INotificationHandler +#endif { private readonly Notification.INotificationFactory _dateTimeReadCallbacks = Notification.CreateFactory(); +#if FEATURE_PERIODIC_TIMER + private readonly Notification.INotificationFactory + _periodicTimerWaitingForNextTickCallbacks = Notification.CreateFactory(); +#endif + private readonly Notification.INotificationFactory _taskDelayCallbacks = Notification.CreateFactory(); @@ -18,6 +31,11 @@ private readonly Notification.INotificationFactory #region INotificationHandler Members +#if FEATURE_PERIODIC_TIMER + /// + public IPeriodicTimerNotificationHandler PeriodicTimer => this; +#endif + /// public IAwaitableCallback DateTimeRead( Action? callback = null, @@ -45,9 +63,27 @@ public IAwaitableCallback TimeChanged( #endregion +#if FEATURE_PERIODIC_TIMER + + #region IPeriodicTimerNotificationHandler Members + + /// + public IAwaitableCallback WaitingForNextTick( + Action? callback = null, Func? predicate = null) + => _periodicTimerWaitingForNextTickCallbacks.RegisterCallback(callback, predicate); + + #endregion + +#endif + public void InvokeDateTimeReadCallbacks(DateTime now) => _dateTimeReadCallbacks.InvokeCallbacks(now); +#if FEATURE_PERIODIC_TIMER + public void InvokePeriodicTimerWaitingForNextTick(IPeriodicTimer timer) + => _periodicTimerWaitingForNextTickCallbacks.InvokeCallbacks(timer); +#endif + public void InvokeTaskDelayCallbacks(TimeSpan delay) => _taskDelayCallbacks.InvokeCallbacks(delay); diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs index bc7ac412..794bf13f 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerFactoryMock.cs @@ -9,11 +9,14 @@ namespace Testably.Abstractions.Testing.TimeSystem; internal sealed class PeriodicTimerFactoryMock : IPeriodicTimerFactory { private readonly MockTimeSystem _mockTimeSystem; + private readonly NotificationHandler _callbackHandler; private readonly bool _autoAdvance; - internal PeriodicTimerFactoryMock(MockTimeSystem timeSystem, bool autoAdvance) + internal PeriodicTimerFactoryMock(MockTimeSystem timeSystem, + NotificationHandler callbackHandler, bool autoAdvance) { _mockTimeSystem = timeSystem; + _callbackHandler = callbackHandler; _autoAdvance = autoAdvance; } @@ -24,7 +27,7 @@ internal PeriodicTimerFactoryMock(MockTimeSystem timeSystem, bool autoAdvance) /// public IPeriodicTimer New(TimeSpan period) - => new PeriodicTimerMock(_mockTimeSystem, period, _autoAdvance); + => new PeriodicTimerMock(_mockTimeSystem, _callbackHandler, period, _autoAdvance); /// public IPeriodicTimer Wrap(PeriodicTimer timer) diff --git a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs index 047077ef..c347056d 100644 --- a/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs +++ b/Source/Testably.Abstractions.Testing/TimeSystem/PeriodicTimerMock.cs @@ -13,13 +13,18 @@ internal sealed class PeriodicTimerMock : IPeriodicTimer private bool _isDisposed; private long _lastTime; private readonly MockTimeSystem _timeSystem; + private readonly NotificationHandler _callbackHandler; - internal PeriodicTimerMock(MockTimeSystem timeSystem, - TimeSpan period, bool autoAdvance) + internal PeriodicTimerMock( + MockTimeSystem timeSystem, + NotificationHandler callbackHandler, + TimeSpan period, + bool autoAdvance) { ThrowIfPeriodIsInvalid(period, nameof(period)); _timeSystem = timeSystem; + _callbackHandler = callbackHandler; _autoAdvance = autoAdvance; _lastTime = _timeSystem.TimeProvider.ElapsedTicks; Period = period; @@ -58,6 +63,7 @@ public void Dispose() return false; } + _callbackHandler.InvokePeriodicTimerWaitingForNextTick(this); long now = _timeSystem.TimeProvider.ElapsedTicks; long nextTime = _lastTime + Period.Ticks; if (nextTime > now) 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 8bb8efd3..ca8846e7 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 @@ -437,11 +437,16 @@ namespace Testably.Abstractions.Testing.TimeSystem { public interface INotificationHandler { + Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; } Testably.Abstractions.Testing.IAwaitableCallback DateTimeRead(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TaskDelay(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback ThreadSleep(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TimeChanged(System.Action? callback = null, System.Func? predicate = null); } + public interface IPeriodicTimerNotificationHandler + { + Testably.Abstractions.Testing.IAwaitableCallback WaitingForNextTick(System.Action? callback = null, System.Func? predicate = null); + } public interface ITimeProvider { long ElapsedTicks { 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 072ad37d..5434f86c 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 @@ -437,11 +437,16 @@ namespace Testably.Abstractions.Testing.TimeSystem { public interface INotificationHandler { + Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; } Testably.Abstractions.Testing.IAwaitableCallback DateTimeRead(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TaskDelay(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback ThreadSleep(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TimeChanged(System.Action? callback = null, System.Func? predicate = null); } + public interface IPeriodicTimerNotificationHandler + { + Testably.Abstractions.Testing.IAwaitableCallback WaitingForNextTick(System.Action? callback = null, System.Func? predicate = null); + } public interface ITimeProvider { long ElapsedTicks { 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 26650fd1..66d22786 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 @@ -437,11 +437,16 @@ namespace Testably.Abstractions.Testing.TimeSystem { public interface INotificationHandler { + Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; } Testably.Abstractions.Testing.IAwaitableCallback DateTimeRead(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TaskDelay(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback ThreadSleep(System.Action? callback = null, System.Func? predicate = null); Testably.Abstractions.Testing.IAwaitableCallback TimeChanged(System.Action? callback = null, System.Func? predicate = null); } + public interface IPeriodicTimerNotificationHandler + { + Testably.Abstractions.Testing.IAwaitableCallback WaitingForNextTick(System.Action? callback = null, System.Func? predicate = null); + } public interface ITimeProvider { long ElapsedTicks { get; } diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/NotificationHandlerTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/NotificationHandlerTests.cs index 78c7842f..80ab1642 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/NotificationHandlerTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/NotificationHandlerTests.cs @@ -1,5 +1,6 @@ using System.Threading; using Testably.Abstractions.Testing.Tests.TestHelpers; +using Testably.Abstractions.TimeSystem; namespace Testably.Abstractions.Testing.Tests.TimeSystem; @@ -87,6 +88,129 @@ public async Task OnDateTimeRead_UtcNow_ShouldExecuteCallbackWithCorrectParamete await That(receivedTime).IsEqualTo(expectedTime); } +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task OnPeriodicTimerWaitingForNextTick_DisposedCallback_ShouldNotBeCalled() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer = null; + using IPeriodicTimer periodicTimer = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + IDisposable disposable = + timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer = t); + + disposable.Dispose(); + await periodicTimer.WaitForNextTickAsync(); + + await That(receivedTimer).IsNull(); + } +#endif + +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task + OnPeriodicTimerWaitingForNextTick_MultipleCallbacks_DisposeOne_ShouldCallOtherCallbacks() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer1 = null; + IPeriodicTimer? receivedTimer2 = null; + using IPeriodicTimer periodicTimer = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer1 = t)) + { + timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer2 = t).Dispose(); + await periodicTimer.WaitForNextTickAsync(); + } + + await That(receivedTimer1).IsEqualTo(periodicTimer); + await That(receivedTimer2).IsNull(); + } +#endif + +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task OnPeriodicTimerWaitingForNextTick_MultipleCallbacks_ShouldAllBeCalled() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer1 = null; + IPeriodicTimer? receivedTimer2 = null; + using IPeriodicTimer periodicTimer = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer1 = t)) + { + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer2 = t)) + { + await periodicTimer.WaitForNextTickAsync(); + } + } + + await That(receivedTimer1).IsEqualTo(periodicTimer); + await That(receivedTimer2).IsEqualTo(periodicTimer); + } +#endif + +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task + OnPeriodicTimerWaitingForNextTick_ShouldBeCalledOnEachWaitForNextTickAsync() + { + MockTimeSystem timeSystem = new(); + int callbackCount = 0; + using IPeriodicTimer periodicTimer = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(_ => callbackCount++)) + { + await periodicTimer.WaitForNextTickAsync(); + await periodicTimer.WaitForNextTickAsync(); + await periodicTimer.WaitForNextTickAsync(); + } + + await That(callbackCount).IsEqualTo(3); + } +#endif + +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task OnPeriodicTimerWaitingForNextTick_ShouldExecuteCallbackWithCorrectParameter() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer = null; + using IPeriodicTimer periodicTimer = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer = t)) + { + await periodicTimer.WaitForNextTickAsync(); + } + + await That(receivedTimer).IsEqualTo(periodicTimer); + } +#endif + +#if FEATURE_PERIODIC_TIMER + [Test] + public async Task OnPeriodicTimerWaitingForNextTick_WithPredicate_ShouldFilterCallbacks() + { + MockTimeSystem timeSystem = new(); + int callbackCount = 0; + using IPeriodicTimer periodicTimer1 = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); + using IPeriodicTimer periodicTimer2 = + timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(2)); + + using IAwaitableCallback _ = timeSystem.On.PeriodicTimer.WaitingForNextTick( + callback: _ => callbackCount++, + predicate: p => p == periodicTimer1); + await periodicTimer1.WaitForNextTickAsync(); + await That(callbackCount).IsEqualTo(1); + await periodicTimer2.WaitForNextTickAsync(); + await That(callbackCount).IsEqualTo(1); + } +#endif + [Test] public async Task OnTaskDelay_DisposedCallback_ShouldNotBeCalled() { diff --git a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs index 726724c7..551efaba 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/TimeSystem/PeriodicTimerMockTests.cs @@ -72,5 +72,111 @@ public async Task ShouldNotBeAffectedByTimeChange() cts.Cancel(); await timerTask; } + + [Test] + public async Task WaitingForNextTick_AutoAdvance_ShouldFireCallback() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer = null; + using IPeriodicTimer periodicTimer = timeSystem.PeriodicTimer.New(1.Seconds()); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer = t)) + { + await periodicTimer.WaitForNextTickAsync(); + } + + await That(receivedTimer).IsEqualTo(periodicTimer); + } + + [Test] + public async Task WaitingForNextTick_DisableAutoAdvance_ShouldFireCallbackBeforeWaiting() + { + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using CancellationTokenSource cts = CancellationTokenSource + .CreateLinkedTokenSource(TestContext.Current!.Execution.CancellationToken); + cts.CancelAfter(30.Seconds()); + CancellationToken token = cts.Token; + + int callbackCount = 0; + using IPeriodicTimer periodicTimer = timeSystem.PeriodicTimer.New(1.Seconds()); + using SemaphoreSlim callbackFired = new(0); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(_ => + { + callbackCount++; + // ReSharper disable once AccessToDisposedClosure + callbackFired.Release(); + })) + { + Task tickTask = Task.Run(async () => + { + try + { + // ReSharper disable once AccessToDisposedClosure + await periodicTimer.WaitForNextTickAsync(token); + } + catch (OperationCanceledException) + { + // Ignore cancellation + } + }, token); + + await callbackFired.WaitAsync(token); + await That(callbackCount).IsEqualTo(1); + + timeSystem.TimeProvider.AdvanceBy(1.Seconds()); + await tickTask; + } + } + + [Test] + public async Task WaitingForNextTick_ShouldFireCallbackForEachTick() + { + MockTimeSystem timeSystem = new(); + int callbackCount = 0; + using IPeriodicTimer periodicTimer = timeSystem.PeriodicTimer.New(1.Seconds()); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(_ => callbackCount++)) + { + await periodicTimer.WaitForNextTickAsync(); + await periodicTimer.WaitForNextTickAsync(); + await periodicTimer.WaitForNextTickAsync(); + } + + await That(callbackCount).IsEqualTo(3); + } + + [Test] + public async Task WaitingForNextTick_ShouldNotFireCallbackAfterDispose() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer = null; + using IPeriodicTimer periodicTimer = timeSystem.PeriodicTimer.New(1.Seconds()); + IDisposable disposable = + timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer = t); + + disposable.Dispose(); + await periodicTimer.WaitForNextTickAsync(); + + await That(receivedTimer).IsNull(); + } + + [Test] + public async Task WaitingForNextTick_WhenTimerIsDisposed_ShouldNotFireCallback() + { + MockTimeSystem timeSystem = new(); + IPeriodicTimer? receivedTimer = null; + IPeriodicTimer periodicTimer = timeSystem.PeriodicTimer.New(1.Seconds()); + periodicTimer.Dispose(); + + using (timeSystem.On.PeriodicTimer.WaitingForNextTick(t => receivedTimer = t)) + { + #pragma warning disable MA0040 // Use an overload with a CancellationToken + await periodicTimer.WaitForNextTickAsync(); + #pragma warning restore MA0040 + } + + await That(receivedTimer).IsNull(); + } } #endif