Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Docs/pages/docs/time-system/configuring-time.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ new MockTimeSystem(TimeProviderFactory.Now());

## Local time zone

`MockTimeSystem` exposes a controllable local time zone through `timeSystem.TimeZoneInfo` (an `ITimeZoneInfo`), and uses it to convert the simulated instant for `DateTime.Now` and `DateTime.Today`. This makes time-zone-dependent code deterministic instead of depending on the machine the test runs on.
`MockTimeSystem` exposes a controllable local time zone through `timeSystem.TimeZoneInfo` (an `ITimeZoneInfo`), and uses it to convert the simulated instant for `DateTime.Now`, `DateTime.Today` and `DateTimeOffset.Now`. This makes time-zone-dependent code deterministic instead of depending on the machine the test runs on.

The factory picks the initial local time zone:

Expand Down Expand Up @@ -51,8 +51,9 @@ TimeZoneInfo tokyo = TimeZoneInfo.CreateCustomTimeZone(
"Test/Tokyo", TimeSpan.FromHours(9), "Test JST", "Test JST");
timeSystem.TimeProvider.LocalTimeZone = tokyo;

DateTime now = timeSystem.DateTime.Now; // 2026-01-15 09:00 (JST)
TimeZoneInfo local = timeSystem.TimeZoneInfo.Local; // Test/Tokyo
DateTime now = timeSystem.DateTime.Now; // 2026-01-15 09:00 (JST)
DateTimeOffset offsetNow = timeSystem.DateTimeOffset.Now; // offset +09:00
TimeZoneInfo local = timeSystem.TimeZoneInfo.Local; // Test/Tokyo

// RegisterTimeZone adds zones resolvable via FindSystemTimeZoneById / GetSystemTimeZones
timeSystem.TimeProvider.RegisterTimeZone(tokyo);
Expand Down
1 change: 1 addition & 0 deletions Docs/pages/docs/time-system/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ title: Time system
| Sub-property | Wraps |
|-------------------|-------------------------------------------------------------------------------------------------------------------------------|
| `DateTime` | `DateTime.Now`, `DateTime.UtcNow`, `DateTime.Today` (plus `MinValue`, `MaxValue`, `UnixEpoch`) |
| `DateTimeOffset` | `DateTimeOffset.Now`, `DateTimeOffset.UtcNow` (plus `MinValue`, `MaxValue`, `UnixEpoch`) |
| `Stopwatch` | [`System.Diagnostics.Stopwatch`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch) |
| `Task` | [`Task.Delay`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay) |
| `Thread` | [`Thread.Sleep`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.sleep) |
Expand Down
7 changes: 3 additions & 4 deletions Source/Testably.Abstractions.Testing/MockTimeSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public INotificationHandler On

private readonly NotificationHandler _callbackHandler;
private readonly DateTimeMock _dateTimeMock;
private readonly DateTimeOffsetMock _dateTimeOffsetMock;
private readonly TimeZoneInfoMock _timeZoneInfoMock;
private readonly StopwatchFactoryMock _stopwatchFactoryMock;
private readonly TaskMock _taskMock;
Expand Down Expand Up @@ -93,6 +94,7 @@ public MockTimeSystem(ITimeProviderFactory timeProvider, Func<MockTimeSystemOpti
_callbackHandler = new NotificationHandler(this);
TimeProvider = timeProvider.Create(_callbackHandler.InvokeTimeChanged);
_dateTimeMock = new DateTimeMock(this, _callbackHandler);
_dateTimeOffsetMock = new DateTimeOffsetMock(this, _callbackHandler);
_timeZoneInfoMock = new TimeZoneInfoMock(this);
_stopwatchFactoryMock = new StopwatchFactoryMock(this);
_threadMock = new ThreadMock(this, _callbackHandler, initialization.AutoAdvance);
Expand All @@ -110,11 +112,8 @@ public IDateTime DateTime
=> _dateTimeMock;

/// <inheritdoc cref="ITimeSystem.DateTimeOffset" />
/// <remarks>
/// The mock implementation for <see cref="System.DateTimeOffset" /> is added in a follow-up change.
/// </remarks>
public IDateTimeOffset DateTimeOffset
=> null!;
=> _dateTimeOffsetMock;

#if FEATURE_PERIODIC_TIMER
/// <inheritdoc cref="ITimeSystem.PeriodicTimer" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using Testably.Abstractions.TimeSystem;

namespace Testably.Abstractions.Testing.TimeSystem;

internal sealed class DateTimeOffsetMock : IDateTimeOffset
{
private readonly NotificationHandler _callbackHandler;
private readonly MockTimeSystem _mockTimeSystem;

internal DateTimeOffsetMock(MockTimeSystem timeSystem,
NotificationHandler callbackHandler)
{
_mockTimeSystem = timeSystem;
_callbackHandler = callbackHandler;
}

#region IDateTimeOffset Members

/// <inheritdoc cref="IDateTimeOffset.MaxValue" />
public DateTimeOffset MaxValue
=> new(_mockTimeSystem.TimeProvider.MaxValue.Ticks, TimeSpan.Zero);

/// <inheritdoc cref="IDateTimeOffset.MinValue" />
public DateTimeOffset MinValue
=> new(_mockTimeSystem.TimeProvider.MinValue.Ticks, TimeSpan.Zero);

/// <inheritdoc cref="IDateTimeOffset.Now" />
public DateTimeOffset Now
{
get
{
DateTimeOffset value = TimeZoneInfo.ConvertTime(
new DateTimeOffset(_mockTimeSystem.TimeProvider.Read().ToUniversalTime()),
_mockTimeSystem.TimeProvider.LocalTimeZone);
_callbackHandler.InvokeDateTimeOffsetReadCallbacks(value);
return value;
}
}

/// <inheritdoc cref="ITimeSystemEntity.TimeSystem" />
public ITimeSystem TimeSystem
=> _mockTimeSystem;

/// <inheritdoc cref="IDateTimeOffset.UnixEpoch" />
public DateTimeOffset UnixEpoch
=> new(_mockTimeSystem.TimeProvider.UnixEpoch.Ticks, TimeSpan.Zero);

/// <inheritdoc cref="IDateTimeOffset.UtcNow" />
public DateTimeOffset UtcNow
{
get
{
DateTimeOffset value = new(_mockTimeSystem.TimeProvider.Read().ToUniversalTime());
_callbackHandler.InvokeDateTimeOffsetReadCallbacks(value);
return value;
}
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ IAwaitableCallback<DateTime> DateTimeRead(
Action<DateTime>? callback = null,
Func<DateTime, bool>? predicate = null);

/// <summary>
/// Callback executed when any of the following <c>DateTimeOffset</c> read methods is called:<br />
/// - <see cref="IDateTimeOffset.Now" /><br />
/// - <see cref="IDateTimeOffset.UtcNow" />
/// </summary>
/// <param name="callback">
/// (optional) The callback to execute after <c>DateTimeOffset</c> was read.
/// </param>
/// <param name="predicate">
/// (optional) A predicate used to filter which callbacks should be notified.<br />
/// If set to <see langword="null" /> (default value) all callbacks are notified.
/// </param>
/// <returns>A <see cref="IAwaitableCallback{DateTimeOffset}" /> to un-register the callback on dispose.</returns>
IAwaitableCallback<DateTimeOffset> DateTimeOffsetRead(
Action<DateTimeOffset>? callback = null,
Func<DateTimeOffset, bool>? predicate = null);

/// <summary>
/// Callback executed when any of the following <c>Task.Delay</c> overloads is called:<br />
/// - <see cref="ITask.Delay(TimeSpan)" /><br />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ internal sealed class NotificationHandler(MockTimeSystem mockTimeSystem)
private readonly Notification.INotificationFactory<DateTime>
_dateTimeReadCallbacks = Notification.CreateFactory<DateTime>();

private readonly Notification.INotificationFactory<DateTimeOffset>
_dateTimeOffsetReadCallbacks = Notification.CreateFactory<DateTimeOffset>();

#if FEATURE_PERIODIC_TIMER
private readonly Notification.INotificationFactory<IPeriodicTimer>
_periodicTimerWaitingForNextTickCallbacks = Notification.CreateFactory<IPeriodicTimer>();
Expand Down Expand Up @@ -42,6 +45,12 @@ public IAwaitableCallback<DateTime> DateTimeRead(
Func<DateTime, bool>? predicate = null)
=> _dateTimeReadCallbacks.RegisterCallback(callback, predicate);

/// <inheritdoc cref="INotificationHandler.DateTimeOffsetRead(Action{DateTimeOffset}?, Func{DateTimeOffset, bool}?)" />
public IAwaitableCallback<DateTimeOffset> DateTimeOffsetRead(
Action<DateTimeOffset>? callback = null,
Func<DateTimeOffset, bool>? predicate = null)
=> _dateTimeOffsetReadCallbacks.RegisterCallback(callback, predicate);

/// <inheritdoc cref="INotificationHandler.TaskDelay(Action{TimeSpan}?, Func{TimeSpan, bool}?)" />
public IAwaitableCallback<TimeSpan> TaskDelay(
Action<TimeSpan>? callback = null,
Expand Down Expand Up @@ -79,6 +88,9 @@ public IAwaitableCallback<IPeriodicTimer> WaitingForNextTick(
public void InvokeDateTimeReadCallbacks(DateTime now)
=> _dateTimeReadCallbacks.InvokeCallbacks(now);

public void InvokeDateTimeOffsetReadCallbacks(DateTimeOffset now)
=> _dateTimeOffsetReadCallbacks.InvokeCallbacks(now);

#if FEATURE_PERIODIC_TIMER
public void InvokePeriodicTimerWaitingForNextTick(IPeriodicTimer timer)
=> _periodicTimerWaitingForNextTickCallbacks.InvokeCallbacks(timer);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
public interface INotificationHandler
{
Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; }
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
{
public interface INotificationHandler
{
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
public interface INotificationHandler
{
Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; }
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
public interface INotificationHandler
{
Testably.Abstractions.Testing.TimeSystem.IPeriodicTimerNotificationHandler PeriodicTimer { get; }
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
{
public interface INotificationHandler
{
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ namespace Testably.Abstractions.Testing.TimeSystem
{
public interface INotificationHandler
{
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTimeOffset> DateTimeOffsetRead(System.Action<System.DateTimeOffset>? callback = null, System.Func<System.DateTimeOffset, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.DateTime> DateTimeRead(System.Action<System.DateTime>? callback = null, System.Func<System.DateTime, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> TaskDelay(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Testably.Abstractions.Testing.IAwaitableCallback<System.TimeSpan> ThreadSleep(System.Action<System.TimeSpan>? callback = null, System.Func<System.TimeSpan, bool>? predicate = null);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace Testably.Abstractions.Testing.Tests.TimeSystem;

public class DateTimeOffsetMockTests
{
[Test]
public async Task Now_ShouldUseConfiguredLocalTimeZoneOffset()
{
DateTime utcNow = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc);
TimeZoneInfo timeZone = TimeZoneInfo.CreateCustomTimeZone(
"Custom/Plus05", TimeSpan.FromHours(5), "Custom +05", "Custom +05");
MockTimeSystem timeSystem = new(TimeProviderFactory.Use(utcNow, timeZone));

DateTimeOffset now = timeSystem.DateTimeOffset.Now;

await That(now.Offset).IsEqualTo(TimeSpan.FromHours(5));
await That(now)
.IsEqualTo(new DateTimeOffset(2024, 1, 15, 17, 0, 0, TimeSpan.FromHours(5)));
}

[Test]
public async Task UtcNow_ShouldUseZeroOffset()
{
DateTime utcNow = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc);
TimeZoneInfo timeZone = TimeZoneInfo.CreateCustomTimeZone(
"Custom/Plus05", TimeSpan.FromHours(5), "Custom +05", "Custom +05");
MockTimeSystem timeSystem = new(TimeProviderFactory.Use(utcNow, timeZone));

DateTimeOffset result = timeSystem.DateTimeOffset.UtcNow;

await That(result.Offset).IsEqualTo(TimeSpan.Zero);
await That(result).IsEqualTo(new DateTimeOffset(utcNow));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,73 @@ public async Task OnDateTimeRead_UtcNow_ShouldExecuteCallbackWithCorrectParamete
await That(receivedTime).IsEqualTo(expectedTime);
}

[Test]
public async Task OnDateTimeOffsetRead_DisposedCallback_ShouldNotBeCalled()
{
DateTime expectedTime = TimeTestHelper.GetRandomTime(DateTimeKind.Local);
MockTimeSystem timeSystem = new(expectedTime);
DateTimeOffset? receivedTime = null;
IDisposable disposable = timeSystem.On.DateTimeOffsetRead(d => receivedTime = d);

disposable.Dispose();
_ = timeSystem.DateTimeOffset.Now;

await That(receivedTime).IsNull();
}

[Test]
public async Task OnDateTimeOffsetRead_MultipleCallbacks_DisposeOne_ShouldCallOtherCallbacks()
{
DateTime expectedTime = TimeTestHelper.GetRandomTime(DateTimeKind.Local);
MockTimeSystem timeSystem = new(expectedTime);
DateTimeOffset? receivedTime1 = null;
DateTimeOffset? receivedTime2 = null;

using (timeSystem.On.DateTimeOffsetRead(d => receivedTime1 = d))
{
timeSystem.On.DateTimeOffsetRead(d => receivedTime2 = d).Dispose();
_ = timeSystem.DateTimeOffset.Now;
}

await That(receivedTime1).IsEqualTo(new DateTimeOffset(expectedTime));
await That(receivedTime2).IsNull();
}

[Test]
public async Task OnDateTimeOffsetRead_MultipleCallbacks_ShouldAllBeCalled()
{
DateTime expectedTime = TimeTestHelper.GetRandomTime(DateTimeKind.Local);
MockTimeSystem timeSystem = new(expectedTime);
DateTimeOffset? receivedTime1 = null;
DateTimeOffset? receivedTime2 = null;

using (timeSystem.On.DateTimeOffsetRead(d => receivedTime1 = d))
{
using (timeSystem.On.DateTimeOffsetRead(d => receivedTime2 = d))
{
_ = timeSystem.DateTimeOffset.Now;
}
}

await That(receivedTime1).IsEqualTo(new DateTimeOffset(expectedTime));
await That(receivedTime2).IsEqualTo(new DateTimeOffset(expectedTime));
}

[Test]
public async Task OnDateTimeOffsetRead_UtcNow_ShouldExecuteCallbackWithCorrectParameter()
{
DateTime expectedTime = TimeTestHelper.GetRandomTime(DateTimeKind.Utc);
MockTimeSystem timeSystem = new(expectedTime);
DateTimeOffset? receivedTime = null;

using (timeSystem.On.DateTimeOffsetRead(d => receivedTime = d))
{
_ = timeSystem.DateTimeOffset.UtcNow;
}

await That(receivedTime).IsEqualTo(new DateTimeOffset(expectedTime, TimeSpan.Zero));
}

#if FEATURE_PERIODIC_TIMER
[Test]
public async Task OnPeriodicTimerWaitingForNextTick_DisposedCallback_ShouldNotBeCalled()
Expand Down
Loading
Loading