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: 1.5, 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), ]; }