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 SignDock, type SignPresentation, type SignRenderOptions, type SignTilt } 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 the gantry window. update() alone does not position a sign. function windowFor(width: number): SignDock { const w = width >= 1280 ? 256 : width >= 768 ? 224 : 160; const h = w / 4; return { left: (width - w) / 2, top: 4, width: w, height: h }; } 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 dock = windowFor(width); let tilt: SignTilt = { x: 0, y: 0 }; sign.setViewportSize(width, height); return { resources, sign, camera, get dock() { return dock; }, 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, dock, now, tilt); } 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); dock = windowFor(width); }, setDock(next: SignDock) { dock = next; }, setTilt(next: SignTilt) { tilt = next; }, }; } function expectDocked(bounds: SignPresentation['bounds'], dock: SignDock, tolerance = 2) { expect(Math.abs(bounds.left - dock.left)).toBeLessThan(tolerance); expect(Math.abs(bounds.right - (dock.left + dock.width))).toBeLessThan(tolerance); expect(Math.abs(bounds.top - dock.top)).toBeLessThan(tolerance); expect(Math.abs(bounds.bottom - (dock.top + dock.height))).toBeLessThan(tolerance); } 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); // Between signs the face holds the resting chapter's label. const resting = face.userData.textureUpdateCount; h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 }); expect(h.sign.group.visible).toBe(false); expect(face.userData.textureUpdateCount).toBe(resting + 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(1); // Docked, the 3D face hands off to the window's flat plate in 200 ms. h.render(profile, 120); expect(face.material.opacity).toBe(0); expect(h.sign.group.visible).toBe(false); expect(face.userData.textureUpdateCount).toBe(resting + 1); expect(h.resources.snapshot()).toEqual(allocation); h.render(null); expect(h.sign.group.visible).toBe(false); const between = face.userData.textureUpdateCount; h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 }); expect(face.userData.textureUpdateCount).toBe(between + 1); 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.each([[1440, 900], [1920, 1080], [1024, 768], [390, 844], [844, 390]])('docks the face exactly into the gantry window at %ix%i', (width, height) => { const h = setup(width, height); const docked = h.render(profile, 120)!; expectDocked(docked.bounds, h.dock); expect(h.sign.group.userData.anchoring).toBe('gantry'); expect(h.sign.dockState()).toBe('docked'); }); it('follows a moved window over successive frames without a one-frame teleport', () => { const h = setup(); const original = h.render(profile, 60)!.bounds.left; h.setDock({ ...h.dock, left: h.dock.left + 300 }); const first = h.render(profile)!.bounds.left; const settled = h.render(profile, 120)!.bounds; expect(first).toBeGreaterThan(original); expect(first - original).toBeLessThan((settled.left - original) / 2); expectDocked(settled, h.dock); }); it('closes in over the road in the upper third, then arcs up into the window', () => { const h = setup(); h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30); const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds; const big = h.render({ ...profile, phase: 'approach', progress: 0.69, opacity: 1 })!.bounds; const arcing = h.render({ ...profile, phase: 'approach', progress: 0.85, opacity: 1 })!.bounds; const docked = h.render(profile)!.bounds; expect(h.sign.dockState()).toBe('docked'); // Grows down the road to about 600 px, centred in the upper third. expect(early.width).toBeLessThan(big.width); expect(big.width).toBeGreaterThan(560); expect(big.width).toBeLessThan(640); expect((big.top + big.bottom) / 2).toBeGreaterThan(900 * 0.2); expect((big.top + big.bottom) / 2).toBeLessThan(900 * 0.4); // Then shrinks and rises into the window, landing within 2 px. expect(arcing.width).toBeLessThan(big.width); expect(arcing.top).toBeLessThan(big.top); expectDocked(docked, h.dock); }); it('leaves the window to the flat plate as it departs', () => { const h = setup(); h.render(profile, 120); // The flat plate holds the window; the 3D face stays hidden as it departs. h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 }); expect(h.sign.dockState()).toBe('passing'); expect(h.sign.group.visible).toBe(false); h.render(null); expect(h.sign.group.visible).toBe(false); expect(h.sign.dockState()).toBe('idle'); }); it('turns toward the pointer only while docked and hovered, and settles back', () => { const h = setup(); h.render(profile, 120); const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } }; const rest = group.quaternion.clone(); // Without hover the plate stays flat: no tilt. h.setTilt({ x: 1, y: -1 }); h.render(profile, 60); expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01); // Hovered, the 3D twin appears and leans at most about 4 degrees. h.setTilt({ x: 1, y: -1, hover: true }); h.render(profile, 60); expect(h.sign.group.visible).toBe(true); expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.05); expect(group.quaternion.angleTo(rest)).toBeLessThan(0.11); h.setTilt({ x: 0, y: 0 }); h.render(profile, 120); expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01); h.setTilt({ x: 1, y: 1, hover: true }); h.render(null); h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60); const approaching = group.quaternion.clone(); expect(approaching.angleTo(h.camera.quaternion)).toBeLessThan(0.01); }); it('shows no sign when the gantry has no window', () => { const h = setup(320, 640); h.setDock({ left: 0, top: 0, width: 0, height: 0 }); h.render(profile, 10); expect(h.sign.group.visible).toBe(false); expect(h.sign.dockState()).toBe('idle'); }); it('reports an incoming sign before it docks', () => { const h = setup(); h.render({ ...profile, phase: 'approach', progress: 0.3, opacity: 1 }); expect(h.sign.dockState()).toBe('incoming'); }); });