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
32 changes: 32 additions & 0 deletions src/Clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = []
Expand Down
10 changes: 5 additions & 5 deletions src/Timeline.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
Expand Down
9 changes: 6 additions & 3 deletions src/TimelineItem/TimelineItem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Clock, type Clockable } from '../Clock.js'
import { getDefaultClock, type Clockable } from '../Clock.js'
import type { Outerface } from '@johngw/outerface'

/**
Expand All @@ -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
}
Expand All @@ -24,7 +24,10 @@ export abstract class TimelineItem<T> {
return this.#rawValue
}

constructor(rawValue: string, { clock = new Clock() }: TimelineItemOptions) {
constructor(
rawValue: string,
{ clock = getDefaultClock() }: TimelineItemOptions,
) {
this.#rawValue = rawValue
this.clock = clock
}
Expand Down
10 changes: 7 additions & 3 deletions src/TimelineItem/TimelineItemTimer.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,9 +38,13 @@ export class TimelineItemTimer extends TimelineItem<TimelineTimer> {
* `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() {
Expand Down Expand Up @@ -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
}
Expand Down
30 changes: 30 additions & 0 deletions test/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
Clock,
type Clockable,
CloseTimeline,
getDefaultClock,
NeverReachTimelineError,
setDefaultClock,
Timeline,
TimelineError,
TimelineTimer,
Expand All @@ -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--<Date>-|',
)
Expand Down Expand Up @@ -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 = {
Expand Down