Files
Portfolio/tests/unit/scene-lifecycle.test.ts

489 lines
19 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('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 } });
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, () => void>();
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): number => {
const id = this.nextId++;
this.timers.set(id, callback);
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);
}
}
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,
};
}