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
147 changes: 147 additions & 0 deletions clock/advance_to_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package clock

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSimulatedAdvanceToMovesToExactTarget(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)
target := mustParse(t, "2026-01-01T04:00:00Z")

require.NoError(t, c.AdvanceTo(target))
assert.True(t, c.Now().Equal(target), "Now() must be exactly the requested target")
}

// TestSimulatedAdvanceToEqualToNowIsANoOp mirrors
// TestSimulatedNowIsExactAfterAdvance's own Advance(0) case: a target
// equal to the current time is valid and leaves time unchanged.
func TestSimulatedAdvanceToEqualToNowIsANoOp(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)

require.NoError(t, c.AdvanceTo(start))
assert.True(t, c.Now().Equal(start))
}

func TestSimulatedAdvanceToRejectsTargetBeforeNow(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)
require.NoError(t, c.AdvanceTo(start.Add(time.Hour)))

err := c.AdvanceTo(start)
require.ErrorIs(t, err, ErrNegativeAdvance)
assert.True(t, c.Now().Equal(start.Add(time.Hour)), "a rejected AdvanceTo must not change the clock")
}

// TestSimulatedAdvanceToRejectsTargetBeforeNowLeavesTimersUnchanged
// proves a rejected AdvanceTo mutates neither time nor timer state: a
// timer that would have fired had the (rejected) advance actually
// happened must still be pending afterward.
func TestSimulatedAdvanceToRejectsTargetBeforeNowLeavesTimersUnchanged(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)
timer := c.NewTimer(30 * time.Minute)

err := c.AdvanceTo(start.Add(-time.Hour))
require.ErrorIs(t, err, ErrNegativeAdvance)

select {
case <-timer.C():
t.Fatal("a rejected AdvanceTo must not fire a timer")
default:
}
}

// TestSimulatedAdvanceToCanonicalizesTarget proves a target expressed
// in a different location, or carrying a monotonic reading, produces
// identical clock state to the equivalent UTC instant -- matching
// NewSimulated's own canonicalization (review feedback on #211).
func TestSimulatedAdvanceToCanonicalizesTarget(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")

loc, err := time.LoadLocation("America/Los_Angeles")
require.NoError(t, err)
// 2026-01-01T04:00:00Z is 2025-12-31T20:00:00-08:00 in Los Angeles.
targetInLA := time.Date(2025, 12, 31, 20, 0, 0, 0, loc)

c := NewSimulated(start)
require.NoError(t, c.AdvanceTo(targetInLA))

assert.Equal(t, time.UTC, c.Now().Location())
assert.True(t, c.Now().Equal(mustParse(t, "2026-01-01T04:00:00Z")))

// A monotonic-carrying target (time.Now()) canonicalizes the same
// way NewSimulated already does -- the resulting Now() carries no
// monotonic reading.
c2 := NewSimulated(start)
require.NoError(t, c2.AdvanceTo(time.Now().Add(time.Hour)))
assert.NotContains(t, c2.Now().String(), " m=")
}

// TestSimulatedAdvanceToFiresMultipleDeadlinesInOneCall mirrors
// TestSimulatedMultipleDeadlinesCrossedInOneAdvance for AdvanceTo:
// crossing several timers' deadlines in one call preserves the
// existing deadline/creation-order firing behavior.
func TestSimulatedAdvanceToFiresMultipleDeadlinesInOneCall(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)
t1 := c.NewTimer(1 * time.Second)
t2 := c.NewTimer(2 * time.Second)
t3 := c.NewTimer(10 * time.Second)

require.NoError(t, c.AdvanceTo(start.Add(5*time.Second)))

for _, timer := range []Timer{t1, t2} {
select {
case <-timer.C():
default:
t.Fatal("a timer whose deadline was crossed must be ready")
}
}
select {
case <-t3.C():
t.Fatal("a timer whose deadline was not crossed must not be ready")
default:
}

assert.True(t, c.Now().Equal(start.Add(5*time.Second)), "Now() must equal the requested target exactly")
}

// TestSimulatedAdvanceToDeterministicAcrossIndependentInstances proves
// two independently constructed clocks given the same start, timers,
// and AdvanceTo sequence produce identical observations (review
// feedback on #211).
func TestSimulatedAdvanceToDeterministicAcrossIndependentInstances(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
targets := []time.Time{
start.Add(1 * time.Hour),
start.Add(90 * time.Minute),
start.Add(4 * time.Hour),
}

run := func() (time.Time, []bool) {
c := NewSimulated(start)
timer := c.NewTimer(2 * time.Hour)
var fired []bool
for _, target := range targets {
require.NoError(t, c.AdvanceTo(target))
select {
case <-timer.C():
fired = append(fired, true)
default:
fired = append(fired, false)
}
}
return c.Now(), fired
}

firstNow, firstFired := run()
secondNow, secondFired := run()
assert.True(t, firstNow.Equal(secondNow))
assert.Equal(t, firstFired, secondFired)
}
34 changes: 31 additions & 3 deletions clock/simulated.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,41 @@ func (s *Simulated) NewTimer(d time.Duration) Timer {
// whose deadline is now due. Advance never waits on wall-clock time and
// returns ErrNegativeAdvance without changing the clock if d is negative.
func (s *Simulated) Advance(d time.Duration) error {
if d < 0 {
return ErrNegativeAdvance
}
s.mu.Lock()
defer s.mu.Unlock()
return s.advanceBy(d)
}

// AdvanceTo moves the clock's current time forward to exactly t, firing
// every timer due at or before t — the primitive a historical-timestamp-
// driven backtest scheduler needs (issue #211, M5-03), so it never has to
// compute t.Sub(Now()) itself at every call site. t is canonicalized to
// UTC with any monotonic reading stripped (matching NewSimulated's own
// canonicalization) before comparison, so equivalent instants expressed
// in different locations or carrying monotonic metadata never produce
// different clock state. AdvanceTo rejects a t before the clock's
// current time with ErrNegativeAdvance, leaving both time and timer
// state unchanged, the same way Advance itself rejects a negative
// duration; t equal to the current time is a valid no-op. AdvanceTo takes
// no context.Context: like Advance, it is a synchronous, bounded state
// mutation with no blocking work, and cancellation policy for a
// potentially long-running replay loop belongs to the caller driving
// that loop (the scheduler, #213), not to this primitive.
func (s *Simulated) AdvanceTo(t time.Time) error {
target := t.UTC().Round(0)
s.mu.Lock()
defer s.mu.Unlock()
return s.advanceBy(target.Sub(s.now))
}

// advanceBy must be called with s.mu held. It applies duration d,
// rejecting a negative one without changing state — the shared core
// both Advance and AdvanceTo delegate to, so the two can never
// disagree about what "advance" means.
func (s *Simulated) advanceBy(d time.Duration) error {
if d < 0 {
return ErrNegativeAdvance
}
s.now = s.now.Add(d)
s.fireDue()
return nil
Expand Down
Loading