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> = { 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, viewportProfile: 'wide', }, }); 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, viewportProfile: 'wide' }), }), 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 | 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; dispose: ReturnType }> = []; const buildContexts: SceneBuildContext[] = []; const pending: Array<((generation: SceneGeneration) => void) | undefined> = []; const makeGeneration = (): SceneGeneration & { render: ReturnType; dispose: ReturnType } => { const generation = { render: vi.fn(), dispose: vi.fn() }; generations.push(generation); return generation; }; const deferredBuilder = () => (context: SceneBuildContext): Promise => { buildContexts.push(context); return new Promise((resolve) => pending.push(resolve)); }; const immediateBuilder = async (context: SceneBuildContext): Promise => { 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(); private timers = new Map 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 { return { scrollY: 0, routeProgress: 0, direction: 0, acceleration: 0, driveSpeed: 0.18, activeSectionId: 'start', chapter: 'deep-night', sign: null, arrived: false, ...overrides, }; }