From 12b74ca869e4c9c4c8964aeb43534db6fb3dfe41 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 5 Jun 2026 13:54:25 +0100 Subject: [PATCH] feat: default timelines to a shared ambient clock, overridable per timeline In practice a source, the code under test, and an expectation always want the same clock, so make sharing the default. Timelines now use an ambient clock (`getDefaultClock`) unless given an explicit `{ clock }`. Swap the ambient clock with `setDefaultClock` (e.g. a fake-timers-backed clock in a test); it returns a restore handle to pair with teardown so the ambient clock doesn't leak between tests. Also advance a passed timer one frame at a time (like `dash`), so timelines sharing a clock interleave in lock-step instead of the source jumping a timer's whole duration synchronously and racing ahead of an expectation's barrier. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Clock.ts | 32 +++++++++++++++++++++++++++ src/Timeline.ts | 10 ++++----- src/TimelineItem/TimelineItem.ts | 9 +++++--- src/TimelineItem/TimelineItemTimer.ts | 10 ++++++--- test/Timeline.test.ts | 30 +++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/Clock.ts b/src/Clock.ts index 8776cbba..09379d8a 100644 --- a/src/Clock.ts +++ b/src/Clock.ts @@ -50,6 +50,38 @@ export interface Clockable { * const expected = Timeline.create('-----T10-2--', { clock }) * ``` */ +let defaultClock: Clockable | undefined + +/** + * The ambient {@link Clockable} a {@link Timeline} uses when none is passed + * explicitly. + * + * @remarks + * Sharing a clock is almost always what you want — a source, the code under + * test, and an expectation all need to advance together — so timelines + * default to this single ambient clock rather than a fresh one each. Replace + * it with {@link setDefaultClock} (e.g. a fake-timers-backed clock in a + * test), or override per timeline with `Timeline.create(str, { clock })`. + */ +export function getDefaultClock(): Clockable { + return (defaultClock ??= new Clock()) +} + +/** + * Sets the ambient {@link Clockable} returned by {@link getDefaultClock} and + * used by new timelines without an explicit `clock`. + * + * @returns a function that restores the previously-set default. Pair it with + * a test's teardown so the ambient clock doesn't leak between tests. + */ +export function setDefaultClock(clock: Clockable): () => void { + const previous = defaultClock + defaultClock = clock + return () => { + defaultClock = previous + } +} + export class Clock implements Clockable { #now = 0 #waiters: { at: number; resolve: () => void }[] = [] diff --git a/src/Timeline.ts b/src/Timeline.ts index 7d45355c..655ce53d 100644 --- a/src/Timeline.ts +++ b/src/Timeline.ts @@ -1,5 +1,5 @@ import { asyncIterableReduce, search } from './util.js' -import { Clock, type Clockable } from './Clock.js' +import { getDefaultClock, type Clockable } from './Clock.js' import { TimelineItemBoolean } from './TimelineItem/TimelineItemBoolean.js' import { TimelineItemClose } from './TimelineItem/TimelineItemClose.js' import { TimelineItemError } from './TimelineItem/TimelineItemError.js' @@ -37,9 +37,9 @@ export type DefaultParsers = typeof DefaultParsers */ export interface TimelineOptions { /** - * The {@link Clockable} that drives this timeline's timing. Pass the same - * instance to multiple timelines to advance them in lockstep so their - * timers line up deterministically. Defaults to a fresh {@link Clock}. + * The {@link Clockable} that drives this timeline's timing. Defaults to the + * shared ambient clock ({@link getDefaultClock}), so timelines coordinate + * by default. Pass an explicit clock to override it for this timeline. */ clock?: Clockable } @@ -82,7 +82,7 @@ export class Timeline< options: TimelineOptions = {}, ) { this.#Parsers = Parsers - this.#clock = options.clock ?? new Clock() + this.#clock = options.clock ?? getDefaultClock() this.#unparsed = timeline.trim() this.#parsed = this.#parse() } diff --git a/src/TimelineItem/TimelineItem.ts b/src/TimelineItem/TimelineItem.ts index 454eb49f..a61d5957 100644 --- a/src/TimelineItem/TimelineItem.ts +++ b/src/TimelineItem/TimelineItem.ts @@ -1,4 +1,4 @@ -import { Clock, type Clockable } from '../Clock.js' +import { getDefaultClock, type Clockable } from '../Clock.js' import type { Outerface } from '@johngw/outerface' /** @@ -8,7 +8,7 @@ export interface TimelineItemOptions { /** * The {@link Clockable} driving this item's timing. {@link Timeline} * passes its shared clock here so all of its items advance together. - * Defaults to a fresh, standalone {@link Clock}. + * Defaults to the shared ambient clock ({@link getDefaultClock}). */ clock?: Clockable } @@ -24,7 +24,10 @@ export abstract class TimelineItem { return this.#rawValue } - constructor(rawValue: string, { clock = new Clock() }: TimelineItemOptions) { + constructor( + rawValue: string, + { clock = getDefaultClock() }: TimelineItemOptions, + ) { this.#rawValue = rawValue this.clock = clock } diff --git a/src/TimelineItem/TimelineItemTimer.ts b/src/TimelineItem/TimelineItemTimer.ts index 1a14fabe..2a9cfd20 100644 --- a/src/TimelineItem/TimelineItemTimer.ts +++ b/src/TimelineItem/TimelineItemTimer.ts @@ -1,4 +1,4 @@ -import { Clock, type Clockable } from '../Clock.js' +import { getDefaultClock, type Clockable } from '../Clock.js' import { outerface } from '@johngw/outerface' import { TimelineItem, @@ -38,9 +38,13 @@ export class TimelineItemTimer extends TimelineItem { * `n` frames of virtual time when a timeline is iterated, so a consumer * never has to wait on {@link TimelineTimer.promise} (which, on a virtual * clock, would deadlock: nothing advances the clock while you await it). + * + * Advanced one frame at a time (like {@link TimelineItem.dash}) so each + * frame yields control — letting a timeline sharing this clock interleave + * in lock-step rather than the source jumping the whole duration at once. */ override async onPass() { - await this.clock.advance(this.#timer.ms) + for (let i = 0; i < this.#timer.ms; i++) await this.clock.advance(1) } get() { @@ -85,7 +89,7 @@ export class TimelineTimer { readonly #ms: number readonly #clock: Clockable - constructor(ms: number, clock: Clockable = new Clock()) { + constructor(ms: number, clock: Clockable = getDefaultClock()) { this.#ms = ms this.#clock = clock } diff --git a/test/Timeline.test.ts b/test/Timeline.test.ts index 8e553576..8794b3f1 100644 --- a/test/Timeline.test.ts +++ b/test/Timeline.test.ts @@ -4,7 +4,9 @@ import { Clock, type Clockable, CloseTimeline, + getDefaultClock, NeverReachTimelineError, + setDefaultClock, Timeline, TimelineError, TimelineTimer, @@ -18,6 +20,9 @@ import { beforeEach, expect, test } from 'bun:test' let timeline: Timeline beforeEach(() => { + // Reset the ambient clock so each test starts from a fresh frame 0 (it's a + // shared singleton by default). + setDefaultClock(new Clock()) timeline = Timeline.create( '--1--{foo: bar}--[a,b]--true--T--false--F--null--N--E--E(err foo)--T10--X---|', ) @@ -160,6 +165,31 @@ test('a shared clock keeps two timelines in lockstep', () => { expect(source.clock).toBe(expected.clock) }) +test('timelines share the ambient clock by default', () => { + const a = Timeline.create('--1--') + const b = Timeline.create('--2--') + expect(a.clock).toBe(getDefaultClock()) + expect(a.clock).toBe(b.clock) +}) + +test('an explicit clock overrides the ambient default', () => { + const clock = new Clock() + const timeline = Timeline.create('--1--', { clock }) + expect(timeline.clock).toBe(clock) + expect(timeline.clock).not.toBe(getDefaultClock()) +}) + +test('setDefaultClock swaps the ambient clock and can restore it', () => { + const original = getDefaultClock() + const replacement = new Clock() + + const restore = setDefaultClock(replacement) + expect(Timeline.create('--1--').clock).toBe(replacement) + + restore() + expect(getDefaultClock()).toBe(original) +}) + test('a timeline accepts any Clockable, not just the built-in Clock', async () => { let now = 0 const clock: Clockable = {