160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import * as THREE from 'three';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import { renderQualityFor } from '@/scene/render-quality';
|
|
import { createChaseCamera } from '@/scene/world/environment';
|
|
import { createCar } from '@/scene/world/car';
|
|
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');
|
|
});
|
|
|
|
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
|
|
const resources = new SceneResourceBag();
|
|
const camera = createChaseCamera(16 / 9);
|
|
const car = createCar(resources);
|
|
car.group.updateMatrixWorld(true);
|
|
camera.updateMatrixWorld(true);
|
|
const bounds = new THREE.Box3().setFromObject(car.group);
|
|
const projected = boxCorners(bounds).map((point) => point.project(camera));
|
|
const minX = Math.min(...projected.map((point) => point.x));
|
|
const maxX = Math.max(...projected.map((point) => point.x));
|
|
const minY = Math.min(...projected.map((point) => point.y));
|
|
const maxY = Math.max(...projected.map((point) => point.y));
|
|
|
|
expect(maxX - minX).toBeGreaterThan(0.22);
|
|
expect(minY).toBeGreaterThan(-0.98);
|
|
expect(maxY).toBeLessThan(-0.15);
|
|
const silhouette = car.group.getObjectByName('rear-silhouette');
|
|
expect(silhouette).toBeDefined();
|
|
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
|
|
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
|
|
expect(Math.max(
|
|
silhouette.material.color.r,
|
|
silhouette.material.color.g,
|
|
silhouette.material.color.b,
|
|
)).toBeGreaterThan(0.12);
|
|
resources.dispose();
|
|
});
|
|
});
|
|
|
|
function boxCorners(bounds: {
|
|
min: { x: number; y: number; z: number };
|
|
max: { x: number; y: number; z: number };
|
|
}) {
|
|
return [
|
|
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
|
|
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
|
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
|
|
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
|
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
|
|
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
|
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
|
|
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
|
];
|
|
}
|