import { afterEach, describe, expect, it } from 'vitest'; import type { SignSnapshot } from '@/journey/types'; import { SceneResourceBag } from '@/scene/resources'; import { createChaseCamera } from '@/scene/world/environment'; import { createSign, routeLabelFor, type SignPresentation, type SignRenderOptions } from '@/scene/world/sign'; interface SignMesh { position: { z: number }; userData: { textureUpdateCount: number }; geometry: { computeBoundingBox(): void; boundingBox: { min: { x: number; y: number }; max: { x: number; y: number; z: number } } | null; getAttribute(name: string): { array: ArrayLike }; }; material: { opacity: number; depthWrite: boolean; toneMapped: boolean; map: { image: { width: number; height: number }; anisotropy: number; premultiplyAlpha: boolean }; }; } const owned: SceneResourceBag[] = []; afterEach(() => { owned.splice(0).forEach(resources => resources.dispose()); }); const profile: SignSnapshot = { targetId: 'profile', phase: 'dwell', progress: 1, opacity: 1 }; // Follow the live sequence: update journey state, then compose with the camera, // viewport and navigation clearance. update() alone does not position a sign. function setup(width = 1440, height = 900, options: SignRenderOptions = {}) { const resources = new SceneResourceBag(); owned.push(resources); const sign = createSign(resources, options); const camera = createChaseCamera(width / height); let now = 0; let navigationBottom = 68; sign.setViewportSize(width, height); return { resources, sign, camera, render(snapshot: SignSnapshot | null, frames = 1) { for (let frame = 0; frame < frames; frame++) { now += 1000 / 60; sign.update(snapshot, now); sign.compose(camera, width, height, navigationBottom, now); } return sign.inspect(camera, width, height); }, resize(nextWidth: number, nextHeight: number) { width = nextWidth; height = nextHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); sign.setViewportSize(width, height); }, setNavigationBottom(bottom: number) { navigationBottom = bottom; }, }; } function expectInViewport(bounds: SignPresentation['bounds'], width: number, height: number, navigationBottom = 68) { expect(bounds.left).toBeGreaterThanOrEqual(8); expect(bounds.right).toBeLessThanOrEqual(width - 8); expect(bounds.top).toBeGreaterThanOrEqual(navigationBottom); expect(bounds.bottom).toBeLessThanOrEqual(height - 8); expect(bounds.width).toBeGreaterThan(80); } describe('composed route signs', () => { it('keeps the readable face in front of its housing with capped, undistorted sampling', () => { const { sign } = setup(); const face = sign.group.getObjectByName('sign-face') as SignMesh; const backplate = sign.group.getObjectByName('sign-backplate') as SignMesh; backplate.geometry.computeBoundingBox(); expect(face.position.z - (backplate.position.z + backplate.geometry.boundingBox!.max.z)).toBeGreaterThan(0.05); face.geometry.computeBoundingBox(); const { min, max } = face.geometry.boundingBox!; expect((max.x - min.x) / (max.y - min.y)).toBeCloseTo(4, 6); const uvs = Array.from(face.geometry.getAttribute('uv').array); expect(Math.min(...uvs)).toBeGreaterThanOrEqual(0); expect(Math.max(...uvs)).toBeLessThanOrEqual(1); expect(face.material.map!.image).toMatchObject({ width: 2048, height: 512 }); expect(face.material.map!.anisotropy).toBeGreaterThanOrEqual(8); expect(face.material.map!.premultiplyAlpha).toBe(true); expect(face.material.depthWrite).toBe(false); expect(face.material.toneMapped).toBe(false); }); it('fades into view, reuses the active label and resources, and hides when cancelled', () => { const h = setup(); const face = h.sign.group.getObjectByName('sign-face') as SignMesh; const allocation = h.resources.snapshot(); h.render(null); expect(h.sign.group.visible).toBe(false); h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 }); expect(h.sign.group.visible).toBe(false); expect(face.userData.textureUpdateCount).toBe(1); h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 0.7 }); expect(h.sign.group.visible).toBe(true); expect(face.material.opacity).toBeGreaterThan(0); expect(face.material.opacity).toBeLessThanOrEqual(0.7); h.render(profile, 120); expect(face.material.opacity).toBe(1); expect(face.userData.textureUpdateCount).toBe(1); expect(h.resources.snapshot()).toEqual(allocation); h.render(null); expect(h.sign.group.visible).toBe(false); h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 }); expect(face.userData.textureUpdateCount).toBe(2); expect(routeLabelFor('selected-builds')).toBe('Selected builds'); expect(h.resources.snapshot()).toEqual(allocation); h.resources.dispose(); expect(h.resources.snapshot().owned).toMatchObject({ geometry: 0, material: 0, texture: 0 }); }); it('caps label texture size and anisotropy to the renderer limits', () => { const h = setup(1440, 900, { maximumAnisotropy: 4, maximumTextureSize: 1024 }); expect(h.render(profile)!.texture).toMatchObject({ width: 1024, height: 256, maximumSize: 1024, anisotropy: 4 }); }); it('accepts a concealed new target without reallocating its label texture', () => { const h = setup(); h.render({ ...profile, phase: 'approach', progress: 0.03, opacity: 0.01 }); const allocation = h.resources.snapshot(); const face = h.sign.group.getObjectByName('sign-face') as SignMesh; const presentation = h.render({ ...profile, targetId: 'experience', phase: 'anticipate', progress: 0, opacity: 0 }); expect(face.userData.textureUpdateCount).toBe(2); expect(presentation!.corridor).toBe('right'); expect(presentation!.visible).toBe(false); expect(h.resources.snapshot()).toEqual(allocation); }); it('eases between roadside and compact layouts over successive rendered frames', () => { const h = setup(); h.render(profile, 120); const origin = h.sign.group.position.clone(); h.resize(844, 390); h.render(profile); const first = h.sign.group.position.clone(); h.render(profile, 10); const midpoint = h.sign.group.position.clone(); const final = h.render(profile, 120)!; const destination = h.sign.group.position.clone(); expect(first.distanceTo(origin)).toBeGreaterThan(0); expect(first.distanceTo(origin)).toBeLessThan(destination.distanceTo(origin) / 2); expect(midpoint.distanceTo(destination)).toBeLessThan(first.distanceTo(destination)); expect(h.sign.group.userData.anchoring).toBe('compact-shelf'); expectInViewport(final.bounds, 844, 390); h.resize(1440, 900); h.render(profile, 120); expect(h.sign.group.userData.anchoring).toBe('roadside'); expect(h.sign.group.position.x).toBeCloseTo(origin.x, 2); }); it('moves the compact shelf below a taller navigator without a one-frame teleport', () => { const h = setup(390, 844); const original = h.render(profile, 60)!.bounds.top; h.setNavigationBottom(180); const first = h.render({ ...profile, navigationArrival: true })!.bounds.top; const settled = h.render({ ...profile, navigationArrival: true }, 120)!.bounds; expect(first).toBeGreaterThan(original); expect(first - original).toBeLessThan((settled.top - original) / 2); expectInViewport(settled, 390, 844, 180); }); it('drives past a roadside sign along the road axis at constant physical scale', () => { const h = setup(); const dwell = h.render(profile, 120)!; const position = h.sign.group.position.clone(); const scale = h.sign.group.scale.x; const passing = h.render({ ...profile, phase: 'depart', progress: 0.5 })!; expect(h.sign.group.position.x).toBeCloseTo(position.x, 6); expect(h.sign.group.position.y).toBeCloseTo(position.y, 6); expect(h.sign.group.position.z).toBeGreaterThan(position.z); expect(h.sign.group.scale.x).toBeCloseTo(scale, 6); expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width); const passingZ = h.sign.group.position.z; h.render({ ...profile, phase: 'depart', progress: 0.9, opacity: 0.1 }); expect(h.sign.group.position.z).toBeGreaterThan(passingZ); expect(h.sign.group.scale.x).toBeCloseTo(scale, 6); h.render(null); expect(h.sign.group.visible).toBe(false); }); it.each([[390, 844], [844, 390], [1440, 900]])('keeps both sign supports on the ground at %ix%i', (width, height) => { const h = setup(width, height); h.render(profile); const footing = h.sign.group.getObjectByName('sign-footing') as { position: { y: number }; children: unknown[] }; expect(h.sign.group.position.y + footing.position.y * h.sign.group.scale.x).toBeCloseTo(0, 6); expect(footing.children).toHaveLength(2); }); it.each([[780, 493], [844, 390]])('keeps short-landscape signs in the right-side shelf at %ix%i', (width, height) => { const h = setup(width, height); const presentation = h.render(profile, 60)!; expectInViewport(presentation.bounds, width, height); expect(presentation.bounds.left).toBeGreaterThan(width / 2); expect(h.sign.group.userData.anchoring).toBe('compact-shelf'); }); it.each([[320, 640], [390, 844], [692, 793], [1024, 768]])('keeps a readable compact navigator sign inside %ix%i', (width, height) => { const h = setup(width, height); const presentation = h.render({ ...profile, navigationArrival: true }, 60)!; expectInViewport(presentation.bounds, width, height); expect(presentation.bounds.width).toBeGreaterThan(200); expect(presentation.bounds.width).toBeLessThan(380); expect(h.sign.group.userData.anchoring).toBe('compact-shelf'); }); it('anchors opposite chapter signs on opposite roadsides and clears the car envelope', () => { const h = setup(); h.render(profile, 120); expect(h.sign.group.position.x).toBeLessThan(-11.6); h.render(null); h.render({ ...profile, targetId: 'experience' }, 120); expect(h.sign.group.position.x).toBeGreaterThan(11.6); expect(h.sign.group.userData.anchoring).toBe('roadside'); }); });