diff --git a/README.md b/README.md index 70b39536..c33d4f91 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 e1b85362..fac9bb42 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 00000000..8776cbba --- /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 5ab4403d..7d45355c 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 60d4042d..454eb49f 100644 --- a/src/TimelineItem/TimelineItem.ts +++ b/src/TimelineItem/TimelineItem.ts @@ -1,18 +1,32 @@ -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 +55,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 +112,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 e4568a66..d3f99a52 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 dbfb8629..3c35f7d2 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 5a81ca3b..5e822cf8 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 1f93aaf3..ccdcc124 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 6fa5d570..ae375056 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 | undefined, 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 761ec21b..c8e6d459 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 9c10cafb..4bcf5715 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 7026a252..9ef41ae8 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 62913543..219f2b73 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 7a8df5fa..f1a0d88a 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, @@ -9,6 +11,7 @@ import { TimelineInstanceOf, TimelineItem, type TimelineParsable, + type TimelineItemOptions, } from '@johngw/timeline' import { beforeEach, expect, test } from 'bun:test' @@ -92,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 } } @@ -115,6 +118,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) }