Table of Contents

Test-clock control APIs (testing)

PrimeTestClock exposes the same control verbs in both stacks — they differ only in the time types each one uses (BCL DateTimeOffset / TimeSpan vs NodaTime Instant / Duration).

Verb Purpose
SetTime / SetInstant Set virtual UTC time; forward targets march through due delays, expiries, and timers; backward targets follow the rules below.
SetLocalTime Set virtual time from a local wall-clock value (lenient DST mapping); same forward/backward semantics as SetTime after resolving to UTC.
Advance Step virtual time forward by marching to each due instant; callbacks see that instant in UtcNow / NowInstant.
RunFor Start a bounded automatic runner that advances virtual time until a stop horizon, then stops and raises lifecycle events.
Start / Stop Toggle the deadline-driven automatic runner; IsRunning reflects the state.

System Clock stack

/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetTime(System.DateTimeOffset)"/> moves the virtual UTC
/// timeline used by <see cref="IPrimeClock.UtcNowDateTimeOffset"/>.
/// </summary>
[Fact]
public void TestClock_SetTime_Advance_UpdatesUtcNow ()
{
    DateTimeOffset start = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero);
    IPrimeTestClock clock = new PrimeTestClock(start);
    clock.SetTime(new DateTimeOffset(2025, 6, 1, 8, 0, 0, TimeSpan.Zero));
    clock.Advance(TimeSpan.FromHours(3));
    clock.UtcNowDateTimeOffset.Should().Be(new DateTimeOffset(2025, 6, 1, 11, 0, 0, TimeSpan.Zero));
}

/// <summary>
/// Verifies <see cref="IPrimeTestClock.RunFor(System.TimeSpan,System.TimeSpan)"/> starts a bounded
/// automatic run and returns immediately, with the caller waiting for completion by observing
/// <see cref="IPrimeTestClock.ClockEvents"/> for <see cref="PrimeTestClockEventType.ClockStopped"/>.
/// </summary>
[Fact]
public void TestClock_RunFor_TimeSpan_WaitsForBoundedCompletion ()
{
    DateTimeOffset start = new(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
    IPrimeTestClock clock = new PrimeTestClock(start);
    TimeSpan step = TimeSpan.FromMinutes(40);
    using ManualResetEventSlim stoppedSignal = new(initialState: false);
    clock.ClockEvents += OnClockEvent;
    try
    {
        bool started = clock.RunFor(step, TimeSpan.FromHours(1));
        started.Should().BeTrue();
        bool stopped = stoppedSignal.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
        stopped.Should().BeTrue("bounded RunFor should publish ClockStopped before timeout");
        clock.IsRunning.Should().BeFalse("clock should not remain running after ClockStopped");
    }
    finally
    {
        clock.ClockEvents -= OnClockEvent;
    }

    clock.UtcNowDateTimeOffset.Should().Be(start + step);
    return;

    void OnClockEvent (object? _, PrimeTestClockEvent clockEvent)
    {
        if (clockEvent.EventType == PrimeTestClockEventType.ClockStopped)
            // ReSharper disable once AccessToDisposedClosure
            stoppedSignal.Set();
    }
}

/// <summary>
/// Verifies <see cref="IPrimeTestClock.Start(System.TimeSpan?)"/> toggles
/// <see cref="IPrimeTestTime.IsRunning"/> and <see cref="IPrimeTestClock.Stop"/> stops automatic advancement.
/// </summary>
[Fact]
public void TestClock_StartStop_TogglesIsRunning ()
{
    IPrimeTestClock clock = new PrimeTestClock(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
    clock.IsRunning.Should().BeFalse();
    clock.Start(TimeSpan.FromSeconds(1));
    clock.IsRunning.Should().BeTrue();
    clock.Stop().Should().BeTrue();
    clock.IsRunning.Should().BeFalse();
}

PrimeTime (NodaTime) stack

/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetInstant"/> moves the virtual UTC timeline used by
/// <see cref="IPrimeClock.NowInstant"/>.
/// </summary>
[Fact]
public void TestClock_SetInstant_Advance_UpdatesNowInstant ()
{
    Instant start = Instant.FromUtc(2025, 1, 1, 12, 0, 0);
    IPrimeTestClock clock = new PrimeTestClock(start);
    clock.SetInstant(Instant.FromUtc(2025, 6, 1, 8, 0, 0));
    clock.Advance(Duration.FromHours(3));
    clock.NowInstant.Should().Be(Instant.FromUtc(2025, 6, 1, 11, 0, 0));
}

/// <summary>
/// Verifies <see cref="IPrimeTestClock.SetLocalTime"/> resolves wall-clock times in the clock's zone,
/// including lenient handling when the wall clock falls in a spring-forward gap.
/// </summary>
[Fact]
public void TestClock_SetLocalTime_LenientSpringGap_ResolvesToValidZonedTime ()
{
    DateTimeZone eastern = NodaDstScenarioFixture.UsEastern;
    LocalDateTime gapWall = new(2024, 3, 10, 2, 30);
    ZonedDateTime lenient = eastern.AtLeniently(gapWall);
    IPrimeTestClock clock = new PrimeTestClock(lenient.ToInstant(), eastern);
    clock.SetLocalTime(gapWall);
    clock.LocalZonedNowInstant.LocalDateTime.Should().Be(lenient.LocalDateTime);
}

/// <summary>
/// Verifies <see cref="IPrimeTestClock.RunFor(Duration,Duration)"/> starts a bounded automatic run and
/// returns immediately, with the caller waiting for completion by observing
/// <see cref="IPrimeTestClock.ClockEvents"/> for <see cref="PrimeTestClockEventType.ClockStopped"/>.
/// </summary>
[Fact]
public void TestClock_RunFor_Duration_WaitsForBoundedCompletion ()
{
    Instant start = Instant.FromUtc(2025, 2, 1, 0, 0, 0);
    IPrimeTestClock clock = new PrimeTestClock(start);
    Duration step = Duration.FromMinutes(40);
    using ManualResetEventSlim stoppedSignal = new(initialState: false);
    clock.ClockEvents += OnClockEvent;
    try
    {
        bool started = clock.RunFor(step, Duration.FromHours(1));
        started.Should().BeTrue();
        bool stopped = stoppedSignal.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
        stopped.Should().BeTrue();
    }
    finally
    {
        clock.ClockEvents -= OnClockEvent;
    }

    clock.NowInstant.Should().Be(start + step);
    return;

    void OnClockEvent (object? _, PrimeTestClockEvent clockEvent)
    {
        if (clockEvent.EventType == PrimeTestClockEventType.ClockStopped)
            // ReSharper disable once AccessToDisposedClosure
            stoppedSignal.Set();
    }
}

/// <summary>
/// Verifies <see cref="IPrimeTestClock.Start(NodaTime.Duration?)"/> toggles
/// <see cref="IPrimeTestTime.IsRunning"/> and <see cref="IPrimeTestClock.Stop"/> stops automatic advancement.
/// </summary>
[Fact]
public void TestClock_StartStop_TogglesIsRunning ()
{
    IPrimeTestClock clock = new PrimeTestClock(Instant.FromUtc(2025, 1, 1, 0, 0, 0));
    clock.IsRunning.Should().BeFalse();
    clock.Start(Duration.FromSeconds(1));
    clock.IsRunning.Should().BeTrue();
    clock.Stop().Should().BeTrue();
    clock.IsRunning.Should().BeFalse();
}

Forward marching (Advance, forward SetTime / SetInstant / SetLocalTime)

  • Virtual time moves to the earliest due instant in the open interval toward the target, dispatches all work due at that instant (delays complete, time expiries cancel, interval and day-time timers fire), then repeats until the horizon is reached.
  • Timer callbacks observe UtcNowDateTimeOffset / NowInstant equal to that firing instant, not only the final horizon.
  • ClockEvents fires once per distinct virtual instant visited during the march.
  • Negative Advance duration is treated as zero (no backward move).

Automatic runner (Start / Stop / RunFor)

  • Start(perSecondRate) runs a background loop that waits for the next virtual deadline among pending delays, time expiries, timers, and a virtual one-minute ClockEvents heartbeat when no other work is due. Virtual delay is mapped to real time using perSecondRate (virtual time per real second).
  • Run rate must be between 100 ms and 1 hour of virtual time per real second (inclusive), or ArgumentOutOfRangeException is thrown. null means 1:1.
  • The runner does not poll every real second. Intended real sleeps shorter than 15 ms are skipped in favor of in-process virtual bursts until the next sleep would be at least 15 ms.
  • After a real wait, virtual catch-up uses measured anchor elapsed time × rate (oversleep produces extra virtual progress), not only the intended timeout.
  • Registering new delays, timers, or time expiries, cancelling work, Stop, or a persist-on-read from a "now" getter can wake the runner so sooner deadlines are not missed.
  • Stop() stops the runner, clears the anchor, and returns true if the clock was running. "Now" getters then return the persisted instant only (no projection).
  • RunFor(duration) starts a bounded run at a default 1:1 rate and returns immediately.
  • RunFor(duration, perSecondRate) starts a bounded run at the supplied rate and returns true when the run starts, or false when the clock is already running.
  • RunFor uses virtual duration (negative treated as zero); completion raises ClockStopped when the stop horizon is reached.
  • To wait for bounded completion in tests, observe IsRunning and/or subscribe to ClockStopped.

"Now" while running (projection and persist-on-read)

  • While Start() is active, UtcNowDateTimeOffset, LocalNow*, and Noda NowInstant / related members project linearly from the last committed virtual instant and a shared Stopwatch anchor at the run rate.
  • Reading "now" also persists: due work up to the projected instant is marched, the committed instant is stored, and the anchor restarts (the runner is signaled to recompute). Frequent reads can be expensive — prefer explicit Advance / RunFor when you do not need projection.
  • See also Timers, daylight saving, and testing.

Backward moves

State Backward SetTime / SetInstant / SetLocalTime
Running InvalidOperationException
Stopped, any active interval timer InvalidOperationException
Stopped, no active interval timers Instant jump, one ClockEvents, no replay of skipped delay/timer work; day-time timers recompute NextDueUtc (at most one callback per discrete time-of-day occurrence)

What to notice (both stacks)

  • Use Advance or forward SetTime when you need intermediate timer and delay callbacks on the path to a target instant.
  • SetLocalTime maps a local wall time in the clock's zone; spring-forward gaps and fall-back overlaps use lenient mapping consistent with production day-time scheduling.
  • Advance is a synchronous deterministic march, while RunFor is a non-blocking bounded automatic runner.