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

241 lines
9.2 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,
particleCount: 384,
streakCount: 48,
postEffects: true,
});
expect(medium).toEqual({
pixelRatio: 1.5,
roadSegments: 144,
terrainSegments: 64,
terrainOctaves: 2,
particleCount: 192,
streakCount: 28,
postEffects: true,
});
expect(low).toEqual({
pixelRatio: 1.25,
roadSegments: 96,
terrainSegments: 32,
terrainOctaves: 1,
particleCount: 64,
streakCount: 12,
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),
];
}