253 lines
9.8 KiB
TypeScript
253 lines
9.8 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';
|
|
import { evaluateRuntimeTraceBudgetEvidence } from '../../scripts/lib/bundle-budgets.mjs';
|
|
|
|
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();
|
|
});
|
|
|
|
it('derives allocation bytes from the actual owned geometry, texture, and render target', () => {
|
|
const bag = new SceneResourceBag();
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
|
|
geometry.setIndex(new THREE.BufferAttribute(new Uint16Array(6), 1));
|
|
bag.own('geometry', geometry);
|
|
bag.own('texture', new THREE.DataTexture(new Uint8Array(4 * 8 * 4), 4, 8));
|
|
bag.own('target', new THREE.WebGLRenderTarget(16, 8, { depthBuffer: true }));
|
|
|
|
const snapshot = bag.gpuBudgetSnapshot({
|
|
tier: 'low',
|
|
defaultFramebuffer: { width: 32, height: 16, bytesPerPixel: 4, attachments: 2, samples: 1 },
|
|
});
|
|
|
|
expect(snapshot.geometryBytes).toBe(12 * 4 + 6 * 2);
|
|
expect(snapshot.textures[0]).toMatchObject({ width: 4, height: 8, bytesPerPixel: 4, layers: 1 });
|
|
expect(snapshot.renderTargets).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ width: 16, height: 8, attachments: 2, samples: 1 }),
|
|
expect.objectContaining({ id: 'default-framebuffer', width: 32, height: 16 }),
|
|
]));
|
|
});
|
|
|
|
it('changes the production ledger when an actual geometry allocation is added', () => {
|
|
const bag = new SceneResourceBag();
|
|
const before = bag.gpuBudgetSnapshot({ tier: 'high', defaultFramebuffer: { width: 1, height: 1, bytesPerPixel: 4, attachments: 2, samples: 1 } });
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(30), 3));
|
|
bag.own('geometry', geometry);
|
|
const after = bag.gpuBudgetSnapshot({ tier: 'high', defaultFramebuffer: { width: 1, height: 1, bytesPerPixel: 4, attachments: 2, samples: 1 } });
|
|
|
|
expect(after.geometryBytes - before.geometryBytes).toBe(30 * 4);
|
|
});
|
|
|
|
it('fails from actual oversized production texture and render-target allocations', () => {
|
|
const textureBag = new SceneResourceBag();
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 4_096;
|
|
canvas.height = 4_096;
|
|
textureBag.own('texture', new THREE.CanvasTexture(canvas));
|
|
const textureResult = evaluateRuntimeTraceBudgetEvidence({
|
|
schema: 'portfolio-runtime-trace-evidence/v1',
|
|
peakRequests: { imageScene: 0, video: 0 },
|
|
allocation: textureBag.gpuBudgetSnapshot({ tier: 'low', defaultFramebuffer: { width: 1, height: 1, bytesPerPixel: 4, attachments: 2, samples: 1 } }),
|
|
});
|
|
expect(textureResult.violations.map((item) => item.budget)).toEqual(expect.arrayContaining(['mobileTextureDimension', 'lowTierGpuEstimate']));
|
|
|
|
const targetBag = new SceneResourceBag();
|
|
targetBag.own('target', new THREE.WebGLRenderTarget(4_096, 4_096, { depthBuffer: true }));
|
|
const targetResult = evaluateRuntimeTraceBudgetEvidence({
|
|
schema: 'portfolio-runtime-trace-evidence/v1',
|
|
peakRequests: { imageScene: 0, video: 0 },
|
|
allocation: targetBag.gpuBudgetSnapshot({ tier: 'low', defaultFramebuffer: { width: 1, height: 1, bytesPerPixel: 4, attachments: 2, samples: 1 } }),
|
|
});
|
|
expect(targetResult.violations.map((item) => item.budget)).toContain('lowTierGpuEstimate');
|
|
});
|
|
});
|
|
|
|
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: 1.5,
|
|
roadSegments: 192,
|
|
terrainSegments: 96,
|
|
terrainOctaves: 3,
|
|
starCount: 384,
|
|
streakCount: 48,
|
|
postScale: 1,
|
|
postEffects: true,
|
|
});
|
|
expect(medium).toEqual({
|
|
pixelRatio: 1.5,
|
|
roadSegments: 144,
|
|
terrainSegments: 64,
|
|
terrainOctaves: 2,
|
|
starCount: 192,
|
|
streakCount: 28,
|
|
postScale: 0.7,
|
|
postEffects: true,
|
|
});
|
|
expect(low).toEqual({
|
|
pixelRatio: 1.25,
|
|
roadSegments: 96,
|
|
terrainSegments: 32,
|
|
terrainOctaves: 1,
|
|
starCount: 64,
|
|
streakCount: 12,
|
|
postScale: 0,
|
|
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') as {
|
|
material: { color: { r: number; g: number; b: number } };
|
|
};
|
|
expect(silhouette).toBeDefined();
|
|
expect(car.group.getObjectByName('rear-light-bar')).toBeDefined();
|
|
expect(car.group.getObjectByName('rear-glass')).toBeDefined();
|
|
expect(car.group.getObjectByName('rear-fascia')).toBeDefined();
|
|
expect(car.group.getObjectByName('rear-wheel-left')).toBeDefined();
|
|
expect(car.group.getObjectByName('rear-wheel-right')).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);
|
|
car.setDetail('reduced');
|
|
expect((car.group.getObjectByName('secondary-detail') as { visible: boolean }).visible).toBe(false);
|
|
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),
|
|
];
|
|
}
|