import { describe, expect, it, vi } from 'vitest'; import * as THREE from 'three'; import type { EnvironmentChapter } from '@/journey/types'; import { renderQualityFor } from '@/scene/render-quality'; import { SceneResourceBag } from '@/scene/resources'; import { CHAPTER_PALETTES, createMutableChapterPalette, writeChapterPalette, } from '@/scene/world/palette'; import { createRoad } from '@/scene/world/road'; import { createTerrain } from '@/scene/world/terrain'; import { createSun } from '@/scene/world/sun'; import { createAtmosphere } from '@/scene/world/atmosphere'; import { createPostProcessor } from '@/scene/post-processing'; describe('procedural world models', () => { it('scales geometry and atmosphere work monotonically by live quality tier', () => { const high = renderQualityFor('high', 3, false); const medium = renderQualityFor('medium', 3, false); const low = renderQualityFor('low', 3, false); expect(high.roadSegments).toBeGreaterThan(medium.roadSegments); expect(medium.roadSegments).toBeGreaterThan(low.roadSegments); expect(high.terrainSegments).toBeGreaterThan(medium.terrainSegments); expect(medium.terrainSegments).toBeGreaterThan(low.terrainSegments); expect(high.streakCount).toBeGreaterThan(low.streakCount); expect(low.postEffects).toBe(false); }); it('builds a shader road whose motion is entirely uniform-driven', () => { const resources = new SceneResourceBag(); const road = createRoad(resources, renderQualityFor('high', 1, false)); const surface = road.group.getObjectByName('road-surface') as ShaderRenderable; expect(surface).toBeInstanceOf(THREE.Mesh); expect(surface.geometry.index).not.toBeNull(); expect(surface.geometry.getAttribute('position').count).toBeGreaterThan(1_000); expect(surface.material.uniforms).toMatchObject({ uDistance: { value: 0 }, uSpeed: { value: 0 }, uAcceleration: { value: 0 }, }); expect(surface.material.fragmentShader).toContain('centerLane'); expect(surface.material.fragmentShader).toContain('outerEdge'); const positionArray = surface.geometry.getAttribute('position').array; const before = resources.snapshot(); for (let index = 0; index < 300; index += 1) { road.update(index * 0.5, 0.65, 0.4); } expect(resources.snapshot()).toEqual(before); expect(surface.geometry.getAttribute('position').array).toBe(positionArray); expect(surface.material.uniforms.uDistance?.value).toBe(149.5); expect(surface.material.uniforms.uSpeed?.value).toBe(0.65); expect(surface.material.uniforms.uAcceleration?.value).toBe(0.4); resources.dispose(); }); it('builds one wide indexed line terrain with a flat road-center mask', () => { const resources = new SceneResourceBag(); const terrain = createTerrain(resources, renderQualityFor('medium', 1, false)); const lines = terrain.group.getObjectByName('terrain-lines') as ShaderRenderable; expect(lines).toBeInstanceOf(THREE.LineSegments); expect(lines.geometry.index).not.toBeNull(); lines.geometry.computeBoundingBox(); expect(lines.geometry.boundingBox).not.toBeNull(); expect( (lines.geometry.boundingBox?.max.x ?? 0) - (lines.geometry.boundingBox?.min.x ?? 0), ).toBeGreaterThan(120); const roadMask = lines.geometry.getAttribute('roadMask'); expect(roadMask).toBeDefined(); const maskValues = Array.from(roadMask.array as ArrayLike); expect(Math.min(...maskValues)).toBe(0); expect(Math.max(...maskValues)).toBeGreaterThan(0.99); expect(lines.material.uniforms).toMatchObject({ uDistance: { value: 0 }, uSpeed: { value: 0 }, uRouteProgress: { value: 0 }, }); expect(lines.material.vertexShader).toContain('multiOctaveNoise'); const indexArray = lines.geometry.index?.array; const positionArray = lines.geometry.getAttribute('position').array; const lowColor = lines.material.uniforms.uLowColor?.value; const highColor = lines.material.uniforms.uHighColor?.value; const before = resources.snapshot(); for (let index = 0; index < 300; index += 1) { terrain.update(index * 0.4, 0.7, 'builds', index / 299); } expect(resources.snapshot()).toEqual(before); expect(lines.geometry.index?.array).toBe(indexArray); expect(lines.geometry.getAttribute('position').array).toBe(positionArray); expect(lines.material.uniforms.uLowColor?.value).toBe(lowColor); expect(lines.material.uniforms.uHighColor?.value).toBe(highColor); expect(lines.material.uniforms.uRouteProgress?.value).toBe(1); resources.dispose(); }); it('writes deterministic chapter blends into stable color allocations', () => { const target = createMutableChapterPalette('deep-night'); const colorReferences = [ target.sky, target.fog, target.terrainLow, target.terrainHigh, target.sun, target.haze, ]; expect(Object.keys(CHAPTER_PALETTES)).toEqual([ 'deep-night', 'systems', 'infrastructure', 'builds', 'pre-dawn', 'destination', ] satisfies EnvironmentChapter[]); expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target); const first = paletteSnapshot(target); expect(writeChapterPalette(target, 'deep-night', 'destination', 0.5)).toBe(target); expect(paletteSnapshot(target)).toEqual(first); expect([ target.sky, target.fog, target.terrainLow, target.terrainHigh, target.sun, target.haze, ]).toEqual(colorReferences); expect(target.warmth).toBeGreaterThan(CHAPTER_PALETTES['deep-night'].warmth); expect(target.warmth).toBeLessThan(CHAPTER_PALETTES.destination.warmth); }); it('renders the sun as a scan-banded disc with a separately owned halo', () => { const resources = new SceneResourceBag(); const sun = createSun(resources); const disc = sun.group.getObjectByName('sun-disc') as ShaderRenderable; const halo = sun.group.getObjectByName('sun-halo'); expect(disc).toBeInstanceOf(THREE.Mesh); expect(halo).toBeInstanceOf(THREE.Sprite); expect(disc.material.uniforms).toMatchObject({ uScanBands: { value: 14 }, uWarmth: { value: CHAPTER_PALETTES['deep-night'].warmth }, }); expect(disc.material.fragmentShader).toContain('scanBand'); expect(resources.snapshot().owned.texture).toBe(1); const palette = createMutableChapterPalette('destination'); sun.update(palette, 1); expect(disc.material.uniforms.uWarmth?.value).toBe(1); expect(disc.material.uniforms.uRouteProgress?.value).toBe(1); resources.dispose(); }); it('builds deterministic stars, haze layers, and quality-capped speed streaks', () => { const highResources = new SceneResourceBag(); const lowResources = new SceneResourceBag(); const highQuality = renderQualityFor('high', 1, false); const lowQuality = renderQualityFor('low', 1, true); const high = createAtmosphere(highResources, highQuality); const low = createAtmosphere(lowResources, lowQuality); const highStars = high.group.getObjectByName('atmosphere-stars') as ShaderRenderable; const lowStars = low.group.getObjectByName('atmosphere-stars') as ShaderRenderable; const highStreaks = high.group.getObjectByName('atmosphere-streaks') as ShaderRenderable; const lowStreaks = low.group.getObjectByName('atmosphere-streaks') as ShaderRenderable; expect(highStars.geometry.getAttribute('position').count).toBe(highQuality.starCount); expect(lowStars.geometry.getAttribute('position').count).toBe(lowQuality.starCount); expect(highStreaks.geometry.getAttribute('position').count).toBe(highQuality.streakCount * 2); expect(lowStreaks.geometry.getAttribute('position').count).toBe(lowQuality.streakCount * 2); expect(high.group.getObjectByName('horizon-haze-near')).toBeInstanceOf(THREE.Mesh); expect(high.group.getObjectByName('horizon-haze-far')).toBeInstanceOf(THREE.Mesh); const highStarPositions = Array.from(highStars.geometry.getAttribute('position').array); const secondResources = new SceneResourceBag(); const secondHigh = createAtmosphere(secondResources, highQuality); const secondStars = secondHigh.group.getObjectByName('atmosphere-stars') as ShaderRenderable; expect(Array.from(secondStars.geometry.getAttribute('position').array)).toEqual(highStarPositions); const palette = createMutableChapterPalette('builds'); const before = highResources.snapshot(); for (let index = 0; index < 300; index += 1) { high.update(index, 0.7, 0.8, palette); } expect(highResources.snapshot()).toEqual(before); expect(highStreaks.material.uniforms.uAcceleration?.value).toBe(0.8); expect(highStreaks.material.uniforms.uVisibility?.value).toBeGreaterThan(0); highResources.dispose(); lowResources.dispose(); secondResources.dispose(); }); it('disposes a composer and its passes exactly once', () => { const resources = new SceneResourceBag(); const composer = { addPass: vi.fn(), setPixelRatio: vi.fn(), setSize: vi.fn(), render: vi.fn(), dispose: vi.fn(), }; const renderPass = { scene: null, camera: null, setSize: vi.fn(), dispose: vi.fn() }; const bloomPass = { setSize: vi.fn(), dispose: vi.fn() }; const processor = createPostProcessor(resources, {}, { createComposer: () => composer, createRenderPass: () => renderPass, createBloomPass: () => bloomPass, }); processor.resize(1_200, 800, 1.25); processor.render({}, {}); processor.dispose(); processor.dispose(); resources.dispose(); expect(composer.addPass).toHaveBeenCalledTimes(2); expect(composer.setPixelRatio).toHaveBeenCalledWith(1.25); expect(composer.setSize).toHaveBeenCalledWith(1_200, 800); expect(composer.render).toHaveBeenCalledOnce(); expect(renderPass.dispose).toHaveBeenCalledOnce(); expect(bloomPass.dispose).toHaveBeenCalledOnce(); expect(composer.dispose).toHaveBeenCalledOnce(); }); }); function paletteSnapshot(palette: ReturnType) { return { sky: palette.sky.getHex(), fog: palette.fog.getHex(), terrainLow: palette.terrainLow.getHex(), terrainHigh: palette.terrainHigh.getHex(), sun: palette.sun.getHex(), haze: palette.haze.getHex(), warmth: palette.warmth, terrainEnergy: palette.terrainEnergy, starVisibility: palette.starVisibility, streakEnergy: palette.streakEnergy, }; } interface ShaderRenderable { geometry: { index: { array: ArrayLike } | null; boundingBox: { min: { x: number }; max: { x: number } } | null; computeBoundingBox(): void; getAttribute(name: string): { count: number; array: ArrayLike }; }; material: { uniforms: Record; vertexShader: string; fragmentShader: string; }; }