diff --git a/clock/advance_to_test.go b/clock/advance_to_test.go new file mode 100644 index 0000000..effffde --- /dev/null +++ b/clock/advance_to_test.go @@ -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) +} diff --git a/clock/simulated.go b/clock/simulated.go index 95e1ead..7500ff9 100644 --- a/clock/simulated.go +++ b/clock/simulated.go @@ -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