feat: build procedural lifecycle-safe drive scene
This commit is contained in:
435
tests/unit/scene-lifecycle.test.ts
Normal file
435
tests/unit/scene-lifecycle.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
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('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' || source === 'paused') expect(controller.snapshot().state.kind).toBe('running');
|
||||
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,
|
||||
};
|
||||
}
|
||||
112
tests/unit/scene-resources.test.ts
Normal file
112
tests/unit/scene-resources.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import { renderQualityFor } from '@/scene/render-quality';
|
||||
import { routeLabelFor } from '@/scene/world/sign';
|
||||
|
||||
describe('SceneResourceBag', () => {
|
||||
it.each([
|
||||
'geometry',
|
||||
'material',
|
||||
'texture',
|
||||
'target',
|
||||
'pass',
|
||||
'renderer',
|
||||
] as const)('disposes an owned %s exactly once', (kind) => {
|
||||
const dispose = vi.fn();
|
||||
const bag = new SceneResourceBag();
|
||||
bag.own(kind, { dispose });
|
||||
|
||||
bag.dispose();
|
||||
bag.dispose();
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
expect(bag.snapshot().disposed).toBe(true);
|
||||
expect(bag.snapshot().owned[kind]).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates the same disposable across ownership categories', () => {
|
||||
const dispose = vi.fn();
|
||||
const resource = { dispose };
|
||||
const bag = new SceneResourceBag();
|
||||
bag.own('geometry', resource);
|
||||
bag.own('material', resource);
|
||||
|
||||
bag.dispose();
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('immediately disposes a resource registered after terminal disposal', () => {
|
||||
const dispose = vi.fn();
|
||||
const bag = new SceneResourceBag();
|
||||
bag.dispose();
|
||||
|
||||
bag.own('texture', { dispose });
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes listeners, disconnects observers, and clears timers and RAF exactly once', () => {
|
||||
const target = new EventTarget();
|
||||
const listener = vi.fn();
|
||||
const observer = { disconnect: vi.fn() };
|
||||
const clearTimer = vi.fn();
|
||||
const cancelFrame = vi.fn();
|
||||
const bag = new SceneResourceBag();
|
||||
|
||||
bag.listen(target, 'ping', listener);
|
||||
bag.observe(observer);
|
||||
bag.timer(7, clearTimer);
|
||||
bag.animationFrame(9, cancelFrame);
|
||||
target.dispatchEvent(new Event('ping'));
|
||||
bag.dispose();
|
||||
bag.dispose();
|
||||
target.dispatchEvent(new Event('ping'));
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(observer.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimer).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimer).toHaveBeenCalledWith(7);
|
||||
expect(cancelFrame).toHaveBeenCalledTimes(1);
|
||||
expect(cancelFrame).toHaveBeenCalledWith(9);
|
||||
});
|
||||
|
||||
it('replaces a tracked RAF without allowing more than one outstanding frame', () => {
|
||||
const cancelFrame = vi.fn();
|
||||
const bag = new SceneResourceBag();
|
||||
|
||||
bag.animationFrame(1, cancelFrame);
|
||||
bag.animationFrame(2, cancelFrame);
|
||||
|
||||
expect(cancelFrame).toHaveBeenCalledOnce();
|
||||
expect(cancelFrame).toHaveBeenCalledWith(1);
|
||||
expect(bag.snapshot().rafCount).toBe(1);
|
||||
|
||||
bag.releaseAnimationFrame(2);
|
||||
expect(bag.snapshot().rafCount).toBe(0);
|
||||
bag.dispose();
|
||||
expect(cancelFrame).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scene render quality', () => {
|
||||
it('reduces GPU work monotonically across live tiers', () => {
|
||||
const high = renderQualityFor('high', 3, false);
|
||||
const medium = renderQualityFor('medium', 3, false);
|
||||
const low = renderQualityFor('low', 3, false);
|
||||
|
||||
expect(high).toEqual({ pixelRatio: 2, terrainSegments: 96, particleCount: 384, postEffects: true });
|
||||
expect(medium).toEqual({ pixelRatio: 1.5, terrainSegments: 64, particleCount: 192, postEffects: true });
|
||||
expect(low).toEqual({ pixelRatio: 1.25, terrainSegments: 32, particleCount: 64, postEffects: false });
|
||||
});
|
||||
|
||||
it('caps mobile and tablet pixel ratio at 1.5 even for High', () => {
|
||||
expect(renderQualityFor('high', 3, true).pixelRatio).toBe(1.5);
|
||||
});
|
||||
|
||||
it('keeps route sign labels canonical and compact', () => {
|
||||
expect(routeLabelFor('start')).toBe('00 / START');
|
||||
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
|
||||
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user