579 lines
22 KiB
TypeScript
579 lines
22 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createSceneController,
|
|
createSceneLifecycleMachine,
|
|
} from '@/scene/create-scene-controller';
|
|
import type {
|
|
SceneBuildContext,
|
|
SceneGeneration,
|
|
SceneLifecycleState,
|
|
SceneScheduler,
|
|
} from '@/scene/types';
|
|
import type { JourneySnapshot } from '@/journey/types';
|
|
|
|
const state = (kind: SceneLifecycleState['kind']): SceneLifecycleState => {
|
|
switch (kind) {
|
|
case 'idle': return { kind };
|
|
case 'loading': return { kind, generation: 1 };
|
|
case 'running': return { kind, generation: 1 };
|
|
case 'paused': return { kind, generation: null, resumeNeedsLoad: true };
|
|
case 'lost': return { kind, lostAt: 100, restorationPending: false };
|
|
case 'restoring': return { kind, generation: 2, lostAt: 100 };
|
|
case 'static': return { kind, reason: 'test' };
|
|
case 'disposed': return { kind };
|
|
}
|
|
};
|
|
|
|
describe('scene lifecycle machine', () => {
|
|
const legal: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
|
|
idle: ['loading', 'paused', 'static', 'disposed'],
|
|
loading: ['running', 'paused', 'lost', 'static', 'disposed'],
|
|
running: ['paused', 'lost', 'static', 'disposed'],
|
|
paused: ['running', 'loading', 'static', 'disposed'],
|
|
lost: ['restoring', 'paused', 'static', 'disposed'],
|
|
restoring: ['running', 'paused', 'lost', 'static', 'disposed'],
|
|
static: ['loading', 'disposed'],
|
|
disposed: [],
|
|
};
|
|
|
|
for (const from of Object.keys(legal) as SceneLifecycleState['kind'][]) {
|
|
for (const to of Object.keys(legal) as SceneLifecycleState['kind'][]) {
|
|
if (from === to) continue;
|
|
const expected = legal[from].includes(to) || (from === 'paused' && to === 'lost');
|
|
it(`${expected ? 'allows' : 'rejects'} ${from} -> ${to}`, () => {
|
|
const initial = from === 'paused' && to === 'lost'
|
|
? { kind: 'paused' as const, generation: 1, resumeNeedsLoad: false }
|
|
: state(from);
|
|
const machine = createSceneLifecycleMachine(initial);
|
|
if (expected) {
|
|
expect(() => machine.transition(state(to))).not.toThrow();
|
|
expect(machine.snapshot().kind).toBe(to);
|
|
} else {
|
|
expect(() => machine.transition(state(to))).toThrow(/illegal transition/i);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
it('rejects paused -> lost when no complete retained context exists', () => {
|
|
const machine = createSceneLifecycleMachine({
|
|
kind: 'paused', generation: null, resumeNeedsLoad: true,
|
|
});
|
|
expect(() => machine.transition(state('lost'))).toThrow(/retained context/i);
|
|
});
|
|
|
|
it('keeps disposed terminal', () => {
|
|
const machine = createSceneLifecycleMachine({ kind: 'disposed' });
|
|
expect(() => machine.transition({ kind: 'running', generation: 9 })).toThrow(/illegal transition/i);
|
|
});
|
|
});
|
|
|
|
describe('scene controller lifecycle', () => {
|
|
it('attaches one generation and schedules at most one RAF', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
|
|
await controller.start();
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'running', generation: 1 },
|
|
canvasCount: 1,
|
|
rafCount: 1,
|
|
currentGeneration: 1,
|
|
});
|
|
|
|
controller.resume();
|
|
controller.resume();
|
|
expect(controller.snapshot().rafCount).toBe(1);
|
|
harness.scheduler.flushFrame(16);
|
|
expect(controller.snapshot().rafCount).toBe(1);
|
|
expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('samples the complete scene update and render interval', async () => {
|
|
const harness = createHarness();
|
|
const renderedFrames: Array<{ start: number; end: number }> = [];
|
|
const controller = createSceneController({
|
|
...harness.options,
|
|
onFrameRendered: (start, end) => renderedFrames.push({ start, end }),
|
|
});
|
|
await controller.start();
|
|
harness.generations[0]?.render.mockImplementation(() => {
|
|
harness.clock.now += 300;
|
|
});
|
|
|
|
harness.scheduler.flushFrame(2_000);
|
|
|
|
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
|
|
});
|
|
|
|
it('enters static five seconds after a live-scene request that never completes its first frame', async () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
const start = controller.start();
|
|
|
|
harness.scheduler.flushTimers(4_999);
|
|
expect(controller.snapshot().state.kind).toBe('loading');
|
|
|
|
harness.scheduler.flushTimers(5_000);
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'static', reason: 'first-frame-timeout' },
|
|
currentGeneration: null,
|
|
rafCount: 0,
|
|
});
|
|
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
|
|
|
|
harness.resolveBuild(0);
|
|
await start;
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('cancels the first-frame watchdog only after a complete update and render', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
|
|
harness.scheduler.flushFrame(16);
|
|
harness.scheduler.flushTimers(5_001);
|
|
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'running', generation: 1 },
|
|
currentGeneration: 1,
|
|
rafCount: 1,
|
|
});
|
|
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the watchdog armed when a frame explicitly reports incomplete', async () => {
|
|
const harness = createHarness();
|
|
const onFrameRendered = vi.fn();
|
|
const controller = createSceneController({ ...harness.options, onFrameRendered });
|
|
await controller.start();
|
|
harness.generations[0]?.render.mockReturnValue(false);
|
|
|
|
harness.scheduler.flushFrame(16);
|
|
harness.scheduler.flushTimers(5_000);
|
|
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'static', reason: 'first-frame-timeout' },
|
|
currentGeneration: null,
|
|
rafCount: 0,
|
|
});
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
expect(onFrameRendered).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('replaces a torn-down generation watchdog with one deadline for its restart', async () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
const first = controller.start();
|
|
harness.clock.now = 1_000;
|
|
controller.pause();
|
|
const second = controller.resume();
|
|
|
|
harness.scheduler.flushTimers(5_000);
|
|
expect(controller.snapshot()).toMatchObject({ state: { kind: 'loading', generation: 2 } });
|
|
|
|
harness.scheduler.flushTimers(6_000);
|
|
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'first-frame-timeout' });
|
|
harness.resolveBuild(0);
|
|
harness.resolveBuild(1);
|
|
await Promise.all([first, second]);
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('retains the same complete generation and frame across a visitor pause', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
const running = controller.snapshot();
|
|
|
|
controller.pause();
|
|
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'paused', generation: running.currentGeneration, resumeNeedsLoad: false },
|
|
currentGeneration: running.currentGeneration,
|
|
canvasCount: 1,
|
|
rafCount: 0,
|
|
});
|
|
expect(harness.generations).toHaveLength(1);
|
|
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
|
|
|
await controller.resume();
|
|
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'running', generation: running.currentGeneration },
|
|
currentGeneration: running.currentGeneration,
|
|
canvasCount: 1,
|
|
rafCount: 1,
|
|
});
|
|
expect(harness.generations).toHaveLength(1);
|
|
});
|
|
|
|
it('copies serializable journey snapshots instead of retaining caller objects', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
const input = journey({
|
|
routeProgress: 0.25,
|
|
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
|
|
});
|
|
|
|
controller.update(input);
|
|
input.routeProgress = 0.9;
|
|
if (input.sign) input.sign.progress = 0.8;
|
|
harness.scheduler.flushFrame(16);
|
|
|
|
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
|
|
expect.objectContaining({ routeProgress: 0.25, sign: expect.objectContaining({ progress: 0.2 }) }),
|
|
16,
|
|
);
|
|
});
|
|
|
|
it('invalidates and disposes an aborted loading generation before a late completion can attach', async () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
const start = controller.start();
|
|
expect(controller.snapshot().state.kind).toBe('loading');
|
|
|
|
controller.pause();
|
|
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
|
|
harness.resolveBuild(0);
|
|
await start;
|
|
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'paused', generation: null, resumeNeedsLoad: true },
|
|
currentGeneration: null,
|
|
rafCount: 0,
|
|
});
|
|
});
|
|
|
|
it('disposes stale async results and lets only the current generation attach', async () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
const first = controller.start();
|
|
controller.pause();
|
|
const second = controller.resume();
|
|
|
|
harness.resolveBuild(0);
|
|
harness.resolveBuild(1);
|
|
await Promise.all([first, second]);
|
|
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
expect(harness.generations[1]?.dispose).not.toHaveBeenCalled();
|
|
expect(controller.snapshot()).toMatchObject({ currentGeneration: 2, canvasCount: 1, rafCount: 1 });
|
|
});
|
|
|
|
it.each(['loading', 'running', 'paused'] as const)('handles first context loss from retained %s state', async (source) => {
|
|
const harness = createHarness({ deferredBuild: source === 'loading' });
|
|
const controller = createSceneController(harness.options);
|
|
const start = controller.start();
|
|
if (source !== 'loading') await start;
|
|
if (source === 'paused') controller.pause();
|
|
const event = new Event('webglcontextlost', { cancelable: true });
|
|
|
|
harness.canvas.dispatchEvent(event);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'lost', lostAt: 0, restorationPending: false },
|
|
rafCount: 0,
|
|
currentGeneration: null,
|
|
});
|
|
if (source === 'loading') {
|
|
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
|
|
harness.resolveBuild(0);
|
|
await start;
|
|
}
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects context loss from a pause without a retained context', () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
void controller.start();
|
|
controller.pause();
|
|
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
|
|
expect(controller.snapshot().state).toEqual({ kind: 'paused', generation: null, resumeNeedsLoad: true });
|
|
});
|
|
|
|
it('defers a hidden restoration and restores on the first visible lifecycle task before cutoff', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
harness.page.visibilityState = 'hidden';
|
|
harness.page.document.dispatchEvent(new Event('visibilitychange'));
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
harness.clock.now = 2000;
|
|
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
|
|
|
|
expect(controller.snapshot().state).toEqual({ kind: 'lost', lostAt: 0, restorationPending: true });
|
|
expect(harness.buildContexts).toHaveLength(1);
|
|
|
|
harness.page.visibilityState = 'visible';
|
|
harness.page.document.dispatchEvent(new Event('visibilitychange'));
|
|
await controller.whenSettled();
|
|
|
|
expect(controller.snapshot().state.kind).toBe('running');
|
|
expect(harness.buildContexts).toHaveLength(2);
|
|
});
|
|
|
|
it('enters static when hidden restoration is first evaluated after the five-second cutoff', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
harness.page.visibilityState = 'hidden';
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
|
|
harness.clock.now = 5000.01;
|
|
|
|
harness.page.visibilityState = 'visible';
|
|
harness.page.document.dispatchEvent(new Event('visibilitychange'));
|
|
await controller.whenSettled();
|
|
|
|
expect(controller.snapshot()).toMatchObject({ state: { kind: 'static', reason: 'context-restore-timeout' }, rafCount: 0 });
|
|
});
|
|
|
|
it('disposes a restoration that finishes after the attach-time cutoff', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
harness.options.build = harness.deferredBuilder();
|
|
harness.clock.now = 1000;
|
|
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
|
|
expect(controller.snapshot().state.kind).toBe('restoring');
|
|
harness.clock.now = 5001;
|
|
harness.resolveBuild(0);
|
|
await controller.whenSettled();
|
|
|
|
expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce();
|
|
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'context-restore-timeout' });
|
|
});
|
|
|
|
it('makes a later context loss terminal static after one successful restoration', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
|
|
await controller.whenSettled();
|
|
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
|
|
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'repeated-context-loss' });
|
|
});
|
|
|
|
it.each([
|
|
['idle', 'idle'],
|
|
['loading', 'paused'],
|
|
['running', 'paused'],
|
|
['paused', 'paused'],
|
|
['static', 'static'],
|
|
] as const)('freezes %s without creating work and restores according to the BFCache table', async (source, frozenKind) => {
|
|
const harness = createHarness({ deferredBuild: source === 'loading' });
|
|
const controller = createSceneController(harness.options);
|
|
let start: Promise<void> | undefined;
|
|
if (source !== 'idle') start = controller.start();
|
|
if (source !== 'idle' && source !== 'loading') await start;
|
|
if (source === 'paused') controller.pause();
|
|
if (source === 'static') controller.setStatic('test');
|
|
|
|
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
|
|
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', state: { kind: frozenKind }, rafCount: 0 });
|
|
const beforeShow = harness.buildContexts.length;
|
|
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
|
|
if (source === 'loading') harness.resolveBuild(1);
|
|
await controller.whenSettled();
|
|
|
|
expect(controller.snapshot().overlay).toBe('active');
|
|
if (source === 'running') expect(controller.snapshot().state.kind).toBe('running');
|
|
if (source === 'paused') {
|
|
expect(controller.snapshot()).toMatchObject({
|
|
state: { kind: 'paused', generation: 1, resumeNeedsLoad: false },
|
|
currentGeneration: 1,
|
|
rafCount: 0,
|
|
});
|
|
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
|
}
|
|
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
|
|
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
|
|
if (start && source === 'loading') {
|
|
harness.resolveBuild(0);
|
|
await start;
|
|
}
|
|
});
|
|
|
|
it('freezes restoration back to lost and applies the original cutoff on pageshow', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
harness.canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }));
|
|
harness.options.build = harness.deferredBuilder();
|
|
harness.canvas.dispatchEvent(new Event('webglcontextrestored'));
|
|
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
|
|
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', state: { kind: 'lost', lostAt: 0 } });
|
|
harness.clock.now = 5001;
|
|
|
|
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
|
|
await controller.whenSettled();
|
|
|
|
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'context-restore-timeout' });
|
|
});
|
|
|
|
it('does not attach a completion while frozen', async () => {
|
|
const harness = createHarness({ deferredBuild: true });
|
|
const controller = createSceneController(harness.options);
|
|
const start = controller.start();
|
|
harness.page.window.dispatchEvent(pageEvent('pagehide', true));
|
|
harness.resolveBuild(0);
|
|
await start;
|
|
|
|
expect(controller.snapshot()).toMatchObject({ overlay: 'frozen', currentGeneration: null, rafCount: 0 });
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('makes non-persisted pagehide and disposal terminal and idempotent', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
|
|
harness.page.window.dispatchEvent(pageEvent('pagehide', false));
|
|
controller.dispose();
|
|
harness.page.window.dispatchEvent(pageEvent('pageshow', true));
|
|
|
|
expect(controller.snapshot()).toMatchObject({ overlay: 'disposed', state: { kind: 'disposed' }, canvasCount: 0, rafCount: 0 });
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
await expect(controller.start()).resolves.toBeUndefined();
|
|
expect(controller.snapshot().state.kind).toBe('disposed');
|
|
});
|
|
|
|
it('restarts with a new generation when quality changes and applies tier settings', async () => {
|
|
const harness = createHarness();
|
|
const controller = createSceneController(harness.options);
|
|
await controller.start();
|
|
|
|
await controller.setQuality('low');
|
|
|
|
expect(harness.buildContexts.map((context) => context.quality)).toEqual(['high', 'low']);
|
|
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
|
|
expect(controller.snapshot()).toMatchObject({ state: { kind: 'running', generation: 2 }, quality: 'low' });
|
|
});
|
|
});
|
|
|
|
function createHarness(options: { deferredBuild?: boolean } = {}) {
|
|
const clock = { now: 0 };
|
|
const scheduler = new FakeScheduler(clock);
|
|
const canvas = new EventTarget();
|
|
const page = {
|
|
document: new EventTarget(),
|
|
window: new EventTarget(),
|
|
visibilityState: 'visible' as DocumentVisibilityState,
|
|
};
|
|
const generations: Array<SceneGeneration & { render: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> }> = [];
|
|
const buildContexts: SceneBuildContext[] = [];
|
|
const pending: Array<((generation: SceneGeneration) => void) | undefined> = [];
|
|
|
|
const makeGeneration = (): SceneGeneration & { render: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn> } => {
|
|
const generation = { render: vi.fn(), dispose: vi.fn() };
|
|
generations.push(generation);
|
|
return generation;
|
|
};
|
|
const deferredBuilder = () => (context: SceneBuildContext): Promise<SceneGeneration> => {
|
|
buildContexts.push(context);
|
|
return new Promise((resolve) => pending.push(resolve));
|
|
};
|
|
const immediateBuilder = async (context: SceneBuildContext): Promise<SceneGeneration> => {
|
|
buildContexts.push(context);
|
|
return makeGeneration();
|
|
};
|
|
const controllerOptions = {
|
|
canvas,
|
|
quality: 'high' as const,
|
|
scheduler,
|
|
page,
|
|
motionAllowed: () => true,
|
|
build: options.deferredBuild ? deferredBuilder() : immediateBuilder,
|
|
};
|
|
|
|
return {
|
|
clock,
|
|
scheduler,
|
|
canvas,
|
|
page,
|
|
generations,
|
|
buildContexts,
|
|
options: controllerOptions,
|
|
deferredBuilder,
|
|
resolveBuild(index: number) {
|
|
const resolve = pending[index];
|
|
if (!resolve) throw new Error(`No pending build ${index}`);
|
|
resolve(makeGeneration());
|
|
},
|
|
};
|
|
}
|
|
|
|
class FakeScheduler implements SceneScheduler {
|
|
private nextId = 1;
|
|
private frames = new Map<number, FrameRequestCallback>();
|
|
private timers = new Map<number, { callback: () => void; dueAt: number }>();
|
|
|
|
constructor(private readonly clock: { now: number }) {}
|
|
|
|
now = () => this.clock.now;
|
|
requestAnimationFrame = (callback: FrameRequestCallback): number => {
|
|
const id = this.nextId++;
|
|
this.frames.set(id, callback);
|
|
return id;
|
|
};
|
|
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
|
|
setTimeout = (callback: () => void, delay: number): number => {
|
|
const id = this.nextId++;
|
|
this.timers.set(id, { callback, dueAt: this.clock.now + delay });
|
|
return id;
|
|
};
|
|
clearTimeout = (id: number): void => { this.timers.delete(id); };
|
|
|
|
flushFrame(now: number): void {
|
|
this.clock.now = now;
|
|
const entries = [...this.frames.entries()];
|
|
this.frames.clear();
|
|
for (const [, callback] of entries) callback(now);
|
|
}
|
|
|
|
flushTimers(now: number): void {
|
|
this.clock.now = now;
|
|
const due = [...this.timers.entries()]
|
|
.filter(([, timer]) => timer.dueAt <= now)
|
|
.sort((left, right) => left[1].dueAt - right[1].dueAt);
|
|
for (const [id, timer] of due) {
|
|
if (!this.timers.delete(id)) continue;
|
|
timer.callback();
|
|
}
|
|
}
|
|
}
|
|
|
|
function pageEvent(type: string, persisted: boolean): Event {
|
|
const event = new Event(type);
|
|
Object.defineProperty(event, 'persisted', { value: persisted });
|
|
return event;
|
|
}
|
|
|
|
function journey(overrides: Partial<JourneySnapshot> = {}): JourneySnapshot {
|
|
return {
|
|
scrollY: 0,
|
|
routeProgress: 0,
|
|
direction: 0,
|
|
acceleration: 0,
|
|
driveSpeed: 0.18,
|
|
activeSectionId: 'start',
|
|
chapter: 'deep-night',
|
|
sign: null,
|
|
arrived: false,
|
|
...overrides,
|
|
};
|
|
}
|