From 7e5298be68c27ce1bb0c8c73492e3c2a65d7e112 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 5 Jun 2026 10:51:05 +0100 Subject: [PATCH 1/3] feat: drive timeline timing with a virtual clock instead of real timers Timing relied on wall-clock `setTimeout` (1ms per dash, `Tn` ms per timer), making it slow, machine-dependent and prone to drift between timelines meant to line up. Replace it with a deterministic virtual `Clock` measured in frames: dashes advance the clock and `Tn` timers resolve off it, so timing is consistent across runs regardless of the event loop. - Add the `Clock` default implementation and the `Clockable` interface - Inject a clock via `Timeline.create(..., { clock })`, defaulting to a fresh one; pass the same instance to advance timelines in lockstep - Thread the clock through `parse`/item constructors via `TimelineItemOptions` - Document driving real timers in code under test with a fake-timers `Clockable` adapter BREAKING CHANGE: `TimelineParsable.parse` and `TimelineItem` constructors now accept a `TimelineItemOptions` argument; custom parsers should forward `options` to super so they share the timeline's clock. `TimelineTimer` is backed by a `Clockable` rather than real wall-clock time. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 147 +++++++++++++++++++-- package.json | 1 + src/Clock.ts | 86 ++++++++++++ src/Timeline.ts | 55 ++++++-- src/TimelineItem/TimelineItem.ts | 35 ++++- src/TimelineItem/TimelineItemBoolean.ts | 14 +- src/TimelineItem/TimelineItemClose.ts | 14 +- src/TimelineItem/TimelineItemDash.ts | 18 ++- src/TimelineItem/TimelineItemDefault.ts | 14 +- src/TimelineItem/TimelineItemError.ts | 14 +- src/TimelineItem/TimelineItemInstance.ts | 14 +- src/TimelineItem/TimelineItemNeverReach.ts | 14 +- src/TimelineItem/TimelineItemNull.ts | 14 +- src/TimelineItem/TimelineItemTimer.ts | 33 +++-- src/index.ts | 1 + test/Timeline.test.ts | 57 ++++++++ 16 files changed, 453 insertions(+), 78 deletions(-) create mode 100644 src/Clock.ts diff --git a/README.md b/README.md index 70b39536e..e14255d73 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,97 @@ for await (const value of timeline) { } ``` +## Consistent timing + +Timelines used to wait on real `setTimeout` timers, which made timing depend on wall-clock time — slow, and prone to drift between two timelines that are meant to line up. Instead, timing is now driven by a virtual `Clock` measured in _frames_ (one frame per dash). The clock only advances as a timeline is consumed, so timing is fully deterministic. + +To coordinate two (or more) timelines — for example a source stream and the expectation it should produce — pass them the **same** `Clock` instance so they advance in lockstep: + +```javascript +import { Clock, Timeline } from '@johngw/timeline' + +const clock = new Clock() +const source = Timeline.create('--1--2------', { clock }) +const expected = Timeline.create('-----T10-2--', { clock }) +``` + +If you don't pass a clock, each `Timeline` gets its own fresh one. + +### Driving real timers (e.g. testing a transformer) + +The `Clock` above is enough when _everything_ runs on timeline time. But real code under test often uses real timers — `setTimeout`, `setInterval`, `Date`. Take a transformer that samples its latest value on an interval: + +```typescript +export function sampleTime(ms: number): TransformStream { + let buffer: T + let hasSample = false + let interval: ReturnType + + return new TransformStream({ + start(controller) { + interval = setInterval(() => { + if (hasSample) controller.enqueue(buffer) + }, ms) + }, + transform(chunk) { + hasSample = true + buffer = chunk + }, + flush: () => clearInterval(interval), + cancel: () => clearInterval(interval), + }) +} +``` + +If you feed a timeline into this, the timeline advances on _frames_ while `setInterval` fires on _wall-clock time_ — they drift, and your test is flaky again. The fix is to put **both** on the same clock. + +A `Timeline` accepts any [`Clockable`](src/Clock.ts), so you don't have to use the built-in `Clock`. Replace the global timers with a fake-timers library such as [`@sinonjs/fake-timers`](https://github.com/sinonjs/fake-timers), then hand the timeline a `Clockable` that reads and advances _that same fake clock_: + +```typescript +import FakeTimers from '@sinonjs/fake-timers' +import { Timeline, type Clockable } from '@johngw/timeline' + +// 1. Make the transformer's setInterval/setTimeout/Date controllable. +const fake = FakeTimers.install({ + toFake: [ + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'Date', + ], +}) + +try { + // 2. A Clockable backed by the *same* fake clock the transformer is on. + // One frame == one fake millisecond. + const clock: Clockable = { + get now() { + return fake.now + }, + wait: (frames) => + new Promise((resolve) => { + fake.setTimeout(resolve, frames) + }), + // `tickAsync` advances fake time *and* drains the promises the stream + // pipeline schedules, so an interval's `enqueue` surfaces before the + // next frame. + advance: (frames = 1) => fake.tickAsync(frames).then(() => {}), + } + + // 3. Consuming a dash now ticks the same clock sampleTime's interval is on, + // so `T20` and `setInterval(20)` fire together — deterministically. + const source = Timeline.create('1-T40--------2--T20--|', { clock }) + const expected = Timeline.create('T20-1-T20-1-T20-2---', { clock }) + + // …drive `source` into `sampleTime(20)` and assert against `expected`. +} finally { + fake.uninstall() +} +``` + +The whole bridge is the `clock` adapter: because the timeline advances that clock as it's consumed, the transformer's real timers advance in lockstep with timeline frames — no manual ticking, and no `setTimeout` left to make timing inconsistent. Note that `install()` is process-global, so install/uninstall it per test (the `try`/`finally` above). + ## Examples See real-world examples in the `@johngw/stream-test` package: @@ -78,7 +169,7 @@ buffer( ### Timers -To signal waiting for a period of milliseconds, use a capital `T` followed by a number, representing the amount of `milliseconds` to wait for. +To signal waiting for a period of time, use a capital `T` followed by a number, representing the amount of time to wait for. For example: @@ -90,6 +181,8 @@ debounce(10) -----T10-2-- ``` +Timing is **virtual**, not wall-clock. A timeline is driven by a [`Clock`](#consistent-timing) that advances one _frame_ per dash; a `Tn` finishes once the clock has advanced `n` frames. This makes timing deterministic and independent of how fast the machine is or how busy the event loop gets — no `setTimeout` is involved. + ### Null Although the keyword `null` can be used, a shorter `N` can also be used. @@ -116,14 +209,21 @@ Each timeline item must have a parser. It should take from **the beginning** of ```typescript import { outerface } from '@johngw/outerface' -import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem' +import { + TimelineItem, + TimelineItemOptions, + TimelineParsable, +} from '@johngw/timeline/TimelineItem' @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result - ? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)] + ? [ + new FooBarTimelineItem(result[1], options), + timeline.slice(result[1].length), + ] : undefined } } @@ -135,14 +235,21 @@ Now we need to implement the rest of the `TimelineItem`. ```typescript import { outerface } from '@johngw/outerface' -import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem' +import { + TimelineItem, + TimelineItemOptions, + TimelineParsable, +} from '@johngw/timeline/TimelineItem' @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result - ? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)] + ? [ + new FooBarTimelineItem(result[1], options), + timeline.slice(result[1].length), + ] : undefined } @@ -177,14 +284,21 @@ This method is called when a timeline item is reached. ```typescript import { outerface } from '@johngw/outerface' -import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem' +import { + TimelineItem, + TimelineItemOptions, + TimelineParsable, +} from '@johngw/timeline/TimelineItem' @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result - ? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)] + ? [ + new FooBarTimelineItem(result[1], options), + timeline.slice(result[1].length), + ] : undefined } @@ -205,14 +319,21 @@ A method that is called just before reaching the next item. ```typescript import { outerface } from '@johngw/outerface' -import { TimelineItem, TimelineParsable } from '@johngw/timeline/TimelineItem' +import { + TimelineItem, + TimelineItemOptions, + TimelineParsable, +} from '@johngw/timeline/TimelineItem' @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result - ? [new FooBarTimelineItem(result[1]), timeline.slice(result[1].length)] + ? [ + new FooBarTimelineItem(result[1], options), + timeline.slice(result[1].length), + ] : undefined } diff --git a/package.json b/package.json index e1b853620..fac9bb423 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "sideEffects": false, "exports": { ".": "./dist/index.js", + "./Clock": "./dist/Clock.js", "./Timeline": "./dist/Timeline.js", "./TimelineItem": "./dist/TimelineItem/TimelineItem.js", "./TimelineItemBoolean": "./dist/TimelineItem/TimelineItemBoolean.js", diff --git a/src/Clock.ts b/src/Clock.ts new file mode 100644 index 000000000..8776cbbab --- /dev/null +++ b/src/Clock.ts @@ -0,0 +1,86 @@ +/** + * The contract a timeline uses to tell the time. + * + * @remarks + * A timeline only needs three things from a clock: read the current time, + * wait for time to pass, and advance time. The built-in {@link Clock} + * implements this with a deterministic frame counter, but you can supply + * any implementation — for example one backed by a fake-timers library so + * the timeline shares a clock with the real `setInterval`/`setTimeout` + * timers inside the code under test. See the README's "Driving real + * timers" section. + */ +export interface Clockable { + /** + * The current virtual time, measured in frames. + */ + readonly now: number + + /** + * Returns a promise that resolves once the clock has advanced by + * `frames` frames. + */ + wait(frames: number): Promise + + /** + * Advances virtual time by `frames` (default 1). May be asynchronous so + * that implementations backed by real/fake timers can flush pending + * timer callbacks and microtasks while advancing. + */ + advance(frames?: number): void | Promise +} + +/** + * The default {@link Clockable}: a virtual clock that drives timeline + * timing deterministically. + * + * @remarks + * Instead of relying on wall-clock timers (`setTimeout`), time is modelled + * as an integer number of "frames". The clock only ever advances when a + * timeline is consumed (one frame per dash), so two timelines sharing the + * same `Clock` instance advance in lockstep and resolve their timers at + * exactly the same point on every run — no matter how fast or slow the + * machine is. + * + * @example + * Share a single clock between two timelines so their timers line up: + * ```typescript + * const clock = new Clock() + * const source = Timeline.create('--1--2------', { clock }) + * const expected = Timeline.create('-----T10-2--', { clock }) + * ``` + */ +export class Clock implements Clockable { + #now = 0 + #waiters: { at: number; resolve: () => void }[] = [] + + /** + * The current virtual time, measured in frames. + */ + get now() { + return this.#now + } + + /** + * Returns a promise that resolves once the clock has advanced by + * `frames` frames. A non-positive `frames` resolves immediately. + */ + wait(frames: number): Promise { + if (frames <= 0) return Promise.resolve() + const at = this.#now + frames + return new Promise((resolve) => { + this.#waiters.push({ at, resolve }) + }) + } + + /** + * Advances virtual time by `frames` (default 1), resolving any + * waiters whose target frame has now been reached. + */ + advance(frames = 1) { + this.#now += frames + const due = this.#waiters.filter((waiter) => waiter.at <= this.#now) + this.#waiters = this.#waiters.filter((waiter) => waiter.at > this.#now) + for (const waiter of due) waiter.resolve() + } +} diff --git a/src/Timeline.ts b/src/Timeline.ts index 5ab4403d7..7d45355c7 100644 --- a/src/Timeline.ts +++ b/src/Timeline.ts @@ -1,4 +1,5 @@ import { asyncIterableReduce, search } from './util.js' +import { Clock, type Clockable } from './Clock.js' import { TimelineItemBoolean } from './TimelineItem/TimelineItemBoolean.js' import { TimelineItemClose } from './TimelineItem/TimelineItemClose.js' import { TimelineItemError } from './TimelineItem/TimelineItemError.js' @@ -30,6 +31,19 @@ export const DefaultParsers = [ export type DefaultParsers = typeof DefaultParsers +/** + * Options accepted by {@link Timeline.create} and the {@link Timeline} + * constructor. + */ +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}. + */ + clock?: Clockable +} + /** * The union of configured {@link TimelineItem} instances. */ @@ -59,35 +73,58 @@ export class Timeline< readonly #unparsed: string readonly #parsed: ParsedTimelineItem[] readonly #Parsers: Parsers + readonly #clock: Clockable #position = -1 - constructor(timeline: string, Parsers: Parsers) { + constructor( + timeline: string, + Parsers: Parsers, + options: TimelineOptions = {}, + ) { this.#Parsers = Parsers + this.#clock = options.clock ?? new Clock() this.#unparsed = timeline.trim() this.#parsed = this.#parse() } - static create(timeline: string): Timeline + static create( + timeline: string, + options?: TimelineOptions, + ): Timeline static create>[]>( timeline: string, Items: Parsers, + options?: TimelineOptions, ): Timeline<[...Parsers, ...DefaultParsers]> static create>[]>( timeline: string, - Items?: Parsers, + ItemsOrOptions?: Parsers | TimelineOptions, + options?: TimelineOptions, ): Timeline<[...Parsers, ...DefaultParsers]> { - return new Timeline<[...Parsers, ...DefaultParsers]>(timeline, [ - ...((Items || []) as Parsers), - ...DefaultParsers, - ]) + const Items = Array.isArray(ItemsOrOptions) ? ItemsOrOptions : [] + const resolvedOptions = Array.isArray(ItemsOrOptions) + ? options + : ItemsOrOptions + return new Timeline<[...Parsers, ...DefaultParsers]>( + timeline, + [...(Items as Parsers), ...DefaultParsers], + resolvedOptions, + ) } get Parsers() { return this.#Parsers } + /** + * The virtual {@link Clock} driving this timeline's timing. + */ + get clock() { + return this.#clock + } + get position() { return this.#position } @@ -174,7 +211,9 @@ ${' '.repeat(length)}^` let $timeline = this.#unparsed while ($timeline.length) { - const result = search(this.#Parsers, (Item) => Item.parse($timeline)) + const result = search(this.#Parsers, (Item) => + Item.parse($timeline, { clock: this.#clock }), + ) if (!result) throw new Error( `Cannot find a TimelineParsable capable of parsing ${$timeline}`, diff --git a/src/TimelineItem/TimelineItem.ts b/src/TimelineItem/TimelineItem.ts index 60d4042df..eccf68f64 100644 --- a/src/TimelineItem/TimelineItem.ts +++ b/src/TimelineItem/TimelineItem.ts @@ -1,18 +1,35 @@ -import { timeout } from '../util.js' +import { Clock, type Clockable } from '../Clock.js' import type { Outerface } from '@johngw/outerface' +/** + * Options shared by every {@link TimelineItem} constructor. + */ +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}. + */ + clock?: Clockable +} + /** * The base class of a timeline item. */ export abstract class TimelineItem { #rawValue: string + protected readonly clock: Clockable get rawValue() { return this.#rawValue } - constructor(rawValue: string) { + constructor( + rawValue: string, + { clock = new Clock() }: TimelineItemOptions = {}, + ) { this.#rawValue = rawValue + this.clock = clock } /** @@ -41,8 +58,12 @@ export abstract class TimelineItem { for (let i = 0; i < length; i++) await this.dash() } - protected dash() { - return timeout(1) + protected async dash() { + // Advancing the clock is the unit of timeline time. Awaiting it yields + // to the microtask queue — so any timeline sharing this clock can react + // to the frame — and lets a real/fake-timer-backed clock flush pending + // timer callbacks before the next frame. + await this.clock.advance(1) } /** @@ -94,8 +115,14 @@ export interface TimelineParsable< * Returns a binary tuple where: * 1. the 1st item is the parsed {@link TimelineItem} * 2. the 2nd item is the **rest** of the unparsed timeline + * + * @param timelinePart - the unparsed timeline, from the current position + * @param options - forwarded to the constructed item; pass `options.clock` + * through to `super`/the item constructor so it shares the timeline's + * {@link Clock}. */ parse( timelinePart: string, + options?: TimelineItemOptions, ): undefined | readonly [timelineItem: T, restOfTimeline: string] } diff --git a/src/TimelineItem/TimelineItemBoolean.ts b/src/TimelineItem/TimelineItemBoolean.ts index e4568a66b..91bc5e0e3 100644 --- a/src/TimelineItem/TimelineItemBoolean.ts +++ b/src/TimelineItem/TimelineItemBoolean.ts @@ -1,5 +1,9 @@ import { outerface } from '@johngw/outerface' -import { type TimelineParsable, TimelineItem } from './TimelineItem.js' +import { + type TimelineItemOptions, + type TimelineParsable, + TimelineItem, +} from './TimelineItem.js' /** * Represents the shorthand for a boolean value, in a timeline. @@ -11,8 +15,8 @@ import { type TimelineParsable, TimelineItem } from './TimelineItem.js' export class TimelineItemBoolean extends TimelineItem { #value: boolean - constructor(rawValue: 'F' | 'T') { - super(rawValue) + constructor(rawValue: 'F' | 'T', options?: TimelineItemOptions) { + super(rawValue, options) this.#value = rawValue === 'T' } @@ -22,11 +26,11 @@ export class TimelineItemBoolean extends TimelineItem { static readonly #regexp = this.createItemRegExp(/([FT])/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ - new TimelineItemBoolean(result[1] as 'F' | 'T'), + new TimelineItemBoolean(result[1] as 'F' | 'T', options), timeline.slice(1), ] as const) : undefined diff --git a/src/TimelineItem/TimelineItemClose.ts b/src/TimelineItem/TimelineItemClose.ts index dbfb86297..f7cc618fe 100644 --- a/src/TimelineItem/TimelineItemClose.ts +++ b/src/TimelineItem/TimelineItemClose.ts @@ -1,5 +1,9 @@ import { outerface } from '@johngw/outerface' -import { type TimelineParsable, TimelineItem } from './TimelineItem.js' +import { + type TimelineItemOptions, + type TimelineParsable, + TimelineItem, +} from './TimelineItem.js' /** * A symbol to represent closing a timeline. @@ -19,8 +23,8 @@ export type CloseTimeline = typeof CloseTimeline */ @outerface>() export class TimelineItemClose extends TimelineItem { - constructor() { - super('|') + constructor(options?: TimelineItemOptions) { + super('|', options) } get(): CloseTimeline { @@ -33,9 +37,9 @@ export class TimelineItemClose extends TimelineItem { static readonly #regexp = this.createItemRegExp(/\|/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { return this.#regexp.test(timeline) - ? ([new TimelineItemClose(), timeline.slice(1)] as const) + ? ([new TimelineItemClose(options), timeline.slice(1)] as const) : undefined } } diff --git a/src/TimelineItem/TimelineItemDash.ts b/src/TimelineItem/TimelineItemDash.ts index 5a81ca3bf..e830201a2 100644 --- a/src/TimelineItem/TimelineItemDash.ts +++ b/src/TimelineItem/TimelineItemDash.ts @@ -1,17 +1,21 @@ import { outerface } from '@johngw/outerface' -import { TimelineItem, type TimelineParsable } from './TimelineItem.js' +import { + TimelineItem, + type TimelineItemOptions, + type TimelineParsable, +} from './TimelineItem.js' /** * Represents a dash in a timeline. * * @remarks - * A dash signifies nothing happening. However, under the hood, - * it's a 1ms delay. + * A dash signifies nothing happening. Under the hood it advances the + * timeline's virtual {@link Clock} by a single frame. */ @outerface>() export class TimelineItemDash extends TimelineItem { - constructor() { - super('-') + constructor(options?: TimelineItemOptions) { + super('-', options) } get() { @@ -22,9 +26,9 @@ export class TimelineItemDash extends TimelineItem { return true } - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { return timeline.startsWith('-') - ? ([new TimelineItemDash(), timeline.slice(1)] as const) + ? ([new TimelineItemDash(options), timeline.slice(1)] as const) : undefined } } diff --git a/src/TimelineItem/TimelineItemDefault.ts b/src/TimelineItem/TimelineItemDefault.ts index 1f93aaf3b..b31256229 100644 --- a/src/TimelineItem/TimelineItemDefault.ts +++ b/src/TimelineItem/TimelineItemDefault.ts @@ -1,6 +1,10 @@ import { takeCharsUntil } from '../util.js' import yaml from 'js-yaml' -import { TimelineItem, type TimelineParsable } from './TimelineItem.js' +import { + TimelineItem, + type TimelineItemOptions, + type TimelineParsable, +} from './TimelineItem.js' import { outerface } from '@johngw/outerface' /** @@ -15,8 +19,8 @@ export type TimelineItemDefaultValue = any export class TimelineItemDefault extends TimelineItem { #value: TimelineItemDefaultValue - constructor(timeline: string) { - super(timeline) + constructor(timeline: string, options?: TimelineItemOptions) { + super(timeline, options) this.#value = yaml.load(timeline) as TimelineItemDefaultValue } @@ -24,11 +28,11 @@ export class TimelineItemDefault extends TimelineItem return this.#value } - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const unparsed = takeCharsUntil(timeline, '-') return unparsed.length ? ([ - new TimelineItemDefault(unparsed), + new TimelineItemDefault(unparsed, options), timeline.slice(unparsed.length), ] as const) : undefined diff --git a/src/TimelineItem/TimelineItemError.ts b/src/TimelineItem/TimelineItemError.ts index 6fa5d570b..4be6298fb 100644 --- a/src/TimelineItem/TimelineItemError.ts +++ b/src/TimelineItem/TimelineItemError.ts @@ -1,5 +1,9 @@ import { outerface } from '@johngw/outerface' -import { type TimelineParsable, TimelineItem } from './TimelineItem.js' +import { + type TimelineItemOptions, + type TimelineParsable, + TimelineItem, +} from './TimelineItem.js' /** * Represents an error in the timeline. @@ -11,8 +15,8 @@ import { type TimelineParsable, TimelineItem } from './TimelineItem.js' export class TimelineItemError extends TimelineItem { #error: TimelineError - constructor(message?: string) { - super(message === undefined ? 'E' : `E(${message})`) + constructor(message?: string, options?: TimelineItemOptions) { + super(message === undefined ? 'E' : `E(${message})`, options) this.#error = new TimelineError(message) } @@ -22,11 +26,11 @@ export class TimelineItemError extends TimelineItem { static readonly #regexp = this.createItemRegExp(/(E(?:\(([^)]*)\))?)/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ - new TimelineItemError(result[2]), + new TimelineItemError(result[2], options), timeline.slice(result[1]!.length), ] as const) : undefined diff --git a/src/TimelineItem/TimelineItemInstance.ts b/src/TimelineItem/TimelineItemInstance.ts index 761ec21b4..3f228e5a3 100644 --- a/src/TimelineItem/TimelineItemInstance.ts +++ b/src/TimelineItem/TimelineItemInstance.ts @@ -1,5 +1,9 @@ import { outerface } from '@johngw/outerface' -import { TimelineItem, type TimelineParsable } from './TimelineItem.js' +import { + TimelineItem, + type TimelineItemOptions, + type TimelineParsable, +} from './TimelineItem.js' /** * A timeline item that would represent an instance of something. @@ -11,8 +15,8 @@ import { TimelineItem, type TimelineParsable } from './TimelineItem.js' export class TimelineItemInstance extends TimelineItem { #name: string - constructor(name: string) { - super(`<${name}>`) + constructor(name: string, options?: TimelineItemOptions) { + super(`<${name}>`, options) this.#name = name } @@ -22,11 +26,11 @@ export class TimelineItemInstance extends TimelineItem { static readonly #regexp = this.createItemRegExp(/(<(\w+)>)/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ - new TimelineItemInstance(result[2]!), + new TimelineItemInstance(result[2]!, options), timeline.slice(result[1]!.length), ] as const) : undefined diff --git a/src/TimelineItem/TimelineItemNeverReach.ts b/src/TimelineItem/TimelineItemNeverReach.ts index 9c10cafb1..09cde6480 100644 --- a/src/TimelineItem/TimelineItemNeverReach.ts +++ b/src/TimelineItem/TimelineItemNeverReach.ts @@ -1,5 +1,9 @@ import { outerface } from '@johngw/outerface' -import { TimelineItem, type TimelineParsable } from './TimelineItem.js' +import { + TimelineItem, + type TimelineItemOptions, + type TimelineParsable, +} from './TimelineItem.js' /** * A timeline item that should never be reached. @@ -11,8 +15,8 @@ import { TimelineItem, type TimelineParsable } from './TimelineItem.js' export class TimelineItemNeverReach extends TimelineItem { #error: NeverReachTimelineError - constructor() { - super('X') + constructor(options?: TimelineItemOptions) { + super('X', options) this.#error = new NeverReachTimelineError() } @@ -26,9 +30,9 @@ export class TimelineItemNeverReach extends TimelineItem>() export class TimelineItemNull extends TimelineItem { - constructor() { - super('N') + constructor(options?: TimelineItemOptions) { + super('N', options) } get() { @@ -17,9 +21,9 @@ export class TimelineItemNull extends TimelineItem { static readonly #regex = this.createItemRegExp(/N/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { return this.#regex.test(timeline) - ? ([new TimelineItemNull(), timeline.slice(1)] as const) + ? ([new TimelineItemNull(options), timeline.slice(1)] as const) : undefined } } diff --git a/src/TimelineItem/TimelineItemTimer.ts b/src/TimelineItem/TimelineItemTimer.ts index 7026a2522..6ada0ef3d 100644 --- a/src/TimelineItem/TimelineItemTimer.ts +++ b/src/TimelineItem/TimelineItemTimer.ts @@ -1,6 +1,10 @@ -import { timeout } from '../util.js' +import { Clock, type Clockable } from '../Clock.js' import { outerface } from '@johngw/outerface' -import { TimelineItem, type TimelineParsable } from './TimelineItem.js' +import { + TimelineItem, + type TimelineItemOptions, + type TimelineParsable, +} from './TimelineItem.js' /** * A timeline item that represents a timer. @@ -14,9 +18,9 @@ import { TimelineItem, type TimelineParsable } from './TimelineItem.js' export class TimelineItemTimer extends TimelineItem { #timer: TimelineTimer - constructor(ms: number) { - super(`T${ms}`) - this.#timer = new TimelineTimer(ms) + constructor(ms: number, options?: TimelineItemOptions) { + super(`T${ms}`, options) + this.#timer = new TimelineTimer(ms, this.clock) } override get finished(): boolean { @@ -34,11 +38,11 @@ export class TimelineItemTimer extends TimelineItem { static readonly #regex = this.createItemRegExp(/(T(\d+))/) - static parse(timeline: string) { + static parse(timeline: string, options?: TimelineItemOptions) { const result = this.#regex.exec(timeline) return result ? ([ - new TimelineItemTimer(Number(result[2])), + new TimelineItemTimer(Number(result[2]), options), timeline.slice(result[1]!.length), ] as const) : undefined @@ -47,6 +51,11 @@ export class TimelineItemTimer extends TimelineItem { /** * Represents a timer in a timeline. + * + * @remarks + * Backed by a virtual {@link Clock} rather than wall-clock time, so a + * timer of `ms` frames finishes exactly when the clock has advanced `ms` + * frames since it started — deterministically, on every run. */ export class TimelineTimer { #state: @@ -63,18 +72,20 @@ export class TimelineTimer { } readonly #ms: number + readonly #clock: Clockable - constructor(ms: number) { + constructor(ms: number, clock: Clockable = new Clock()) { this.#ms = ms + this.#clock = clock } start() { - const start = Date.now() + const start = this.#clock.now this.#state = { started: true, start, end: start + this.#ms, - promise: timeout(this.#ms), + promise: this.#clock.wait(this.#ms), } } @@ -99,7 +110,7 @@ export class TimelineTimer { } get timeLeft() { - return this.#state.started ? this.#state.end - Date.now() : undefined + return this.#state.started ? this.#state.end - this.#clock.now : undefined } get started() { diff --git a/src/index.ts b/src/index.ts index 629135437..219f2b73d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './Clock.js' export * from './Timeline.js' export * from './TimelineItem/TimelineItem.js' export * from './TimelineItem/TimelineItemBoolean.js' diff --git a/test/Timeline.test.ts b/test/Timeline.test.ts index 7a8df5faf..620c6bdff 100644 --- a/test/Timeline.test.ts +++ b/test/Timeline.test.ts @@ -1,6 +1,8 @@ import { asyncIterableToArray } from '../src/util.js' import { outerface } from '@johngw/outerface' import { + Clock, + type Clockable, CloseTimeline, NeverReachTimelineError, Timeline, @@ -115,6 +117,61 @@ test('custom parser', async () => { ]) }) +test('consuming a timeline advances its clock one frame per dash', async () => { + const timeline = Timeline.create('--1--|') + expect(timeline.clock.now).toBe(0) + await asyncIterableToArray(timeline) + // 6 single-character items (-, -, 1, -, -, |), each passed as one frame. + expect(timeline.clock.now).toBe(6) +}) + +test('timers finish deterministically off the shared clock, no real time', async () => { + const clock = new Clock() + const timeline = Timeline.create('T3--|', { clock }) + + const { value } = await timeline.next() + const timer = value!.get() as TimelineTimer + expect(timer).toBeInstanceOf(TimelineTimer) + + // Reached but not enough frames have elapsed yet. + expect(timer.started).toBe(true) + expect(timer.finished).toBe(false) + expect(timer.timeLeft).toBe(3) + + // Advancing the shared clock — not wall time — finishes it. + clock.advance(3) + expect(timer.finished).toBe(true) + expect(timer.timeLeft).toBe(0) + await timer.promise +}) + +test('a shared clock keeps two timelines in lockstep', () => { + const clock = new Clock() + const source = Timeline.create('--1--2------', { clock }) + const expected = Timeline.create('-----T10-2--', { clock }) + expect(source.clock).toBe(expected.clock) +}) + +test('a timeline accepts any Clockable, not just the built-in Clock', async () => { + let now = 0 + const clock: Clockable = { + get now() { + return now + }, + wait: () => Promise.resolve(), + advance: (frames = 1) => { + now += frames + }, + } + + const timeline = Timeline.create('--|', { clock }) + expect(timeline.clock).toBe(clock) + + await asyncIterableToArray(timeline) + // '-', '-', '|' => one frame each. + expect(now).toBe(3) +}) + function dashes(amount: number) { return new Array(amount).fill(undefined) } From 40bf49d135a46388584d544c6fed3864dffc5fe4 Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 5 Jun 2026 10:56:57 +0100 Subject: [PATCH 2/3] refactor: dont allow empty options in items This will make extending more deterministic --- README.md | 8 ++++---- src/TimelineItem/TimelineItem.ts | 7 ++----- src/TimelineItem/TimelineItemBoolean.ts | 4 ++-- src/TimelineItem/TimelineItemClose.ts | 4 ++-- src/TimelineItem/TimelineItemDash.ts | 4 ++-- src/TimelineItem/TimelineItemDefault.ts | 4 ++-- src/TimelineItem/TimelineItemError.ts | 4 ++-- src/TimelineItem/TimelineItemInstance.ts | 4 ++-- src/TimelineItem/TimelineItemNeverReach.ts | 4 ++-- src/TimelineItem/TimelineItemNull.ts | 4 ++-- src/TimelineItem/TimelineItemTimer.ts | 4 ++-- 11 files changed, 24 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e14255d73..c33d4f91a 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ import { @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result ? [ @@ -243,7 +243,7 @@ import { @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result ? [ @@ -292,7 +292,7 @@ import { @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result ? [ @@ -327,7 +327,7 @@ import { @outerface>() export class FooBarTimelineItem extends TimelineItem { - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.createItemRegExp('(FOO)').exec(timeline) return result ? [ diff --git a/src/TimelineItem/TimelineItem.ts b/src/TimelineItem/TimelineItem.ts index eccf68f64..454eb49f5 100644 --- a/src/TimelineItem/TimelineItem.ts +++ b/src/TimelineItem/TimelineItem.ts @@ -24,10 +24,7 @@ export abstract class TimelineItem { return this.#rawValue } - constructor( - rawValue: string, - { clock = new Clock() }: TimelineItemOptions = {}, - ) { + constructor(rawValue: string, { clock = new Clock() }: TimelineItemOptions) { this.#rawValue = rawValue this.clock = clock } @@ -123,6 +120,6 @@ export interface TimelineParsable< */ parse( timelinePart: string, - options?: TimelineItemOptions, + options: TimelineItemOptions, ): undefined | readonly [timelineItem: T, restOfTimeline: string] } diff --git a/src/TimelineItem/TimelineItemBoolean.ts b/src/TimelineItem/TimelineItemBoolean.ts index 91bc5e0e3..d3f99a527 100644 --- a/src/TimelineItem/TimelineItemBoolean.ts +++ b/src/TimelineItem/TimelineItemBoolean.ts @@ -15,7 +15,7 @@ import { export class TimelineItemBoolean extends TimelineItem { #value: boolean - constructor(rawValue: 'F' | 'T', options?: TimelineItemOptions) { + constructor(rawValue: 'F' | 'T', options: TimelineItemOptions) { super(rawValue, options) this.#value = rawValue === 'T' } @@ -26,7 +26,7 @@ export class TimelineItemBoolean extends TimelineItem { static readonly #regexp = this.createItemRegExp(/([FT])/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ diff --git a/src/TimelineItem/TimelineItemClose.ts b/src/TimelineItem/TimelineItemClose.ts index f7cc618fe..3c35f7d2c 100644 --- a/src/TimelineItem/TimelineItemClose.ts +++ b/src/TimelineItem/TimelineItemClose.ts @@ -23,7 +23,7 @@ export type CloseTimeline = typeof CloseTimeline */ @outerface>() export class TimelineItemClose extends TimelineItem { - constructor(options?: TimelineItemOptions) { + constructor(options: TimelineItemOptions) { super('|', options) } @@ -37,7 +37,7 @@ export class TimelineItemClose extends TimelineItem { static readonly #regexp = this.createItemRegExp(/\|/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { return this.#regexp.test(timeline) ? ([new TimelineItemClose(options), timeline.slice(1)] as const) : undefined diff --git a/src/TimelineItem/TimelineItemDash.ts b/src/TimelineItem/TimelineItemDash.ts index e830201a2..5e822cf84 100644 --- a/src/TimelineItem/TimelineItemDash.ts +++ b/src/TimelineItem/TimelineItemDash.ts @@ -14,7 +14,7 @@ import { */ @outerface>() export class TimelineItemDash extends TimelineItem { - constructor(options?: TimelineItemOptions) { + constructor(options: TimelineItemOptions) { super('-', options) } @@ -26,7 +26,7 @@ export class TimelineItemDash extends TimelineItem { return true } - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { return timeline.startsWith('-') ? ([new TimelineItemDash(options), timeline.slice(1)] as const) : undefined diff --git a/src/TimelineItem/TimelineItemDefault.ts b/src/TimelineItem/TimelineItemDefault.ts index b31256229..ccdcc1246 100644 --- a/src/TimelineItem/TimelineItemDefault.ts +++ b/src/TimelineItem/TimelineItemDefault.ts @@ -19,7 +19,7 @@ export type TimelineItemDefaultValue = any export class TimelineItemDefault extends TimelineItem { #value: TimelineItemDefaultValue - constructor(timeline: string, options?: TimelineItemOptions) { + constructor(timeline: string, options: TimelineItemOptions) { super(timeline, options) this.#value = yaml.load(timeline) as TimelineItemDefaultValue } @@ -28,7 +28,7 @@ export class TimelineItemDefault extends TimelineItem return this.#value } - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const unparsed = takeCharsUntil(timeline, '-') return unparsed.length ? ([ diff --git a/src/TimelineItem/TimelineItemError.ts b/src/TimelineItem/TimelineItemError.ts index 4be6298fb..ae375056c 100644 --- a/src/TimelineItem/TimelineItemError.ts +++ b/src/TimelineItem/TimelineItemError.ts @@ -15,7 +15,7 @@ import { export class TimelineItemError extends TimelineItem { #error: TimelineError - constructor(message?: string, options?: TimelineItemOptions) { + constructor(message: string | undefined, options: TimelineItemOptions) { super(message === undefined ? 'E' : `E(${message})`, options) this.#error = new TimelineError(message) } @@ -26,7 +26,7 @@ export class TimelineItemError extends TimelineItem { static readonly #regexp = this.createItemRegExp(/(E(?:\(([^)]*)\))?)/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ diff --git a/src/TimelineItem/TimelineItemInstance.ts b/src/TimelineItem/TimelineItemInstance.ts index 3f228e5a3..c8e6d4595 100644 --- a/src/TimelineItem/TimelineItemInstance.ts +++ b/src/TimelineItem/TimelineItemInstance.ts @@ -15,7 +15,7 @@ import { export class TimelineItemInstance extends TimelineItem { #name: string - constructor(name: string, options?: TimelineItemOptions) { + constructor(name: string, options: TimelineItemOptions) { super(`<${name}>`, options) this.#name = name } @@ -26,7 +26,7 @@ export class TimelineItemInstance extends TimelineItem { static readonly #regexp = this.createItemRegExp(/(<(\w+)>)/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.#regexp.exec(timeline) return result ? ([ diff --git a/src/TimelineItem/TimelineItemNeverReach.ts b/src/TimelineItem/TimelineItemNeverReach.ts index 09cde6480..4bcf5715e 100644 --- a/src/TimelineItem/TimelineItemNeverReach.ts +++ b/src/TimelineItem/TimelineItemNeverReach.ts @@ -15,7 +15,7 @@ import { export class TimelineItemNeverReach extends TimelineItem { #error: NeverReachTimelineError - constructor(options?: TimelineItemOptions) { + constructor(options: TimelineItemOptions) { super('X', options) this.#error = new NeverReachTimelineError() } @@ -30,7 +30,7 @@ export class TimelineItemNeverReach extends TimelineItem>() export class TimelineItemNull extends TimelineItem { - constructor(options?: TimelineItemOptions) { + constructor(options: TimelineItemOptions) { super('N', options) } @@ -21,7 +21,7 @@ export class TimelineItemNull extends TimelineItem { static readonly #regex = this.createItemRegExp(/N/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { return this.#regex.test(timeline) ? ([new TimelineItemNull(options), timeline.slice(1)] as const) : undefined diff --git a/src/TimelineItem/TimelineItemTimer.ts b/src/TimelineItem/TimelineItemTimer.ts index 6ada0ef3d..9ef41ae83 100644 --- a/src/TimelineItem/TimelineItemTimer.ts +++ b/src/TimelineItem/TimelineItemTimer.ts @@ -18,7 +18,7 @@ import { export class TimelineItemTimer extends TimelineItem { #timer: TimelineTimer - constructor(ms: number, options?: TimelineItemOptions) { + constructor(ms: number, options: TimelineItemOptions) { super(`T${ms}`, options) this.#timer = new TimelineTimer(ms, this.clock) } @@ -38,7 +38,7 @@ export class TimelineItemTimer extends TimelineItem { static readonly #regex = this.createItemRegExp(/(T(\d+))/) - static parse(timeline: string, options?: TimelineItemOptions) { + static parse(timeline: string, options: TimelineItemOptions) { const result = this.#regex.exec(timeline) return result ? ([ From 5c8b70dbdce67259fa97b17b4e9a68848453caac Mon Sep 17 00:00:00 2001 From: John Wright Date: Fri, 5 Jun 2026 11:03:33 +0100 Subject: [PATCH 3/3] fix: ensure outerface is correctly typed --- test/Timeline.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/Timeline.test.ts b/test/Timeline.test.ts index 620c6bdff..f1a0d88a4 100644 --- a/test/Timeline.test.ts +++ b/test/Timeline.test.ts @@ -11,6 +11,7 @@ import { TimelineInstanceOf, TimelineItem, type TimelineParsable, + type TimelineItemOptions, } from '@johngw/timeline' import { beforeEach, expect, test } from 'bun:test' @@ -94,9 +95,9 @@ test('custom parser', async () => { return 'BAR' as const } - static parse(timeline: string) { + static parse(timeline: string, options: TimelineItemOptions) { return timeline.startsWith('FOO') - ? ([new FooParser('FOO'), timeline.slice(3)] as const) + ? ([new FooParser('FOO', options), timeline.slice(3)] as const) : undefined } }