Exercise sign state updates and composition together, covering responsive placement, grounding, label reuse, resource ownership, and departure motion. Update poster and visual-model expectations while retaining behavioral checks. Validation: 46 focused tests passed before commit. The preceding full build:preview run passed all 673 tests plus typecheck, builds, prerender, artifact verification, and transfer budgets.
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
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<number> };
|
|
};
|
|
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');
|
|
});
|
|
});
|