Files
Portfolio/tests/unit/underpass.test.ts

281 lines
12 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
createShadeLatch,
DECK_FAR_Z,
DECK_PASS_Z,
deckZ,
gapProgress,
SHADE_PEAK,
shadeFor,
slewShade,
underpassTravel,
UNDERPASS_EXITS,
} from '@/journey/underpass';
import { roadOpenness } from '@/scene/world/headlights';
import { SceneResourceBag } from '@/scene/resources';
import { createUnderpass, UNDERPASS_GEOMETRY, underpassUniforms } from '@/scene/world/underpass';
import { dampPalette, createMutableChapterPalette, paletteDampRate, writeChapterPalette } from '@/scene/world/palette';
import { readSwitchboard } from '@/audio/music-pulse';
import { playUnderpassPass } from '@/audio/underpass-pass';
const activation = (active: number, next: number | null) => ({
layoutRevision: 1,
activeHeadingOffsetVh: active,
nextHeadingOffsetVh: next,
});
const gap = (id: SectionId, active: number, next: number | null) => ({
activeSectionId: id,
sectionActivation: activation(active, next),
});
describe('underpass gap progress', () => {
it('measures the gap exactly as the road openness does', () => {
for (const [active, next] of [[0, 1], [-0.4, 0.6], [-0.95, 0.05], [-1.3, 0.4], [0.2, 1.2]] as const) {
const p = gapProgress(gap('experience', active, next));
expect(p).not.toBeNull();
const expected = THREE.MathUtils.clamp(-active / (next - active), 0, 1);
expect(p).toBeCloseTo(expected, 10);
// Same p, same openness curve.
const open = 1 - THREE.MathUtils.smoothstep(p!, 0.02, 0.12) * (1 - THREE.MathUtils.smoothstep(p!, 0.8, 0.92));
expect(roadOpenness(gap('experience', -p!, 1 - p!))).toBeCloseTo(open, 10);
}
});
it('is null with no next heading, a non-positive span or non-finite offsets', () => {
expect(gapProgress(gap('contact', -0.2, null))).toBeNull();
expect(gapProgress(gap('profile', 0.3, 0.3))).toBeNull();
expect(gapProgress(gap('profile', 0.5, 0.2))).toBeNull();
expect(gapProgress(gap('profile', Number.NaN, 0.2))).toBeNull();
expect(gapProgress(gap('profile', 0, Number.POSITIVE_INFINITY))).toBeNull();
expect(roadOpenness(gap('contact', -0.2, null))).toBe(0);
});
it("measures 'start' too (its boundary is exit 02), where the road openness is always open", () => {
expect(roadOpenness(gap('start', -0.5, 0.5))).toBe(1);
expect(gapProgress(gap('start', -0.5, 0.5))).toBeCloseTo(0.5, 10);
expect(underpassTravel(gap('start', -0.98, 0.02))?.exit).toBe('profile');
});
});
describe('underpass deck travel', () => {
it('slides from the horizon at p 0.45 to over the camera at p 1, monotonic and reversible', () => {
expect(deckZ(0)).toBe(DECK_FAR_Z);
expect(deckZ(0.45)).toBe(DECK_FAR_Z);
expect(deckZ(1)).toBeCloseTo(DECK_PASS_Z, 10);
expect(DECK_FAR_Z).toBe(-190);
// Overhead means the chase camera's z (23-26 across the chapters).
expect(DECK_PASS_Z).toBe(25);
const forward: number[] = [];
for (let i = 0; i <= 200; i += 1) forward.push(deckZ(i / 200));
for (let i = 1; i < forward.length; i += 1) expect(forward[i]!).toBeGreaterThanOrEqual(forward[i - 1]!);
const back: number[] = [];
for (let i = 200; i >= 0; i -= 1) back.push(deckZ(i / 200));
expect(back.reverse()).toEqual(forward);
});
it('does not leave the horizon before the exit sign fires, then passes over at the line', () => {
// A long chapter: the sign fires with the next heading 0.55 vh below the line.
const span = 3;
expect(underpassTravel(gap('experience', -(span - 0.6), 0.6))).toBeNull();
const fired = underpassTravel(gap('experience', -(span - 0.55), 0.55));
expect(fired?.exit).toBe('infrastructure');
expect(fired?.z).toBeCloseTo(DECK_FAR_Z, 6);
expect(underpassTravel(gap('experience', -span, 0))?.z).toBeCloseTo(DECK_PASS_Z, 6);
// Narrower trigger (a tall screen): the deck waits longer.
expect(underpassTravel(gap('experience', -(span - 0.4), 0.4), 0.23)).toBeNull();
});
it('carries the same deck on behind the camera past the cut, and scrolls back under it', () => {
const before = underpassTravel(gap('profile', -0.999, 0.001));
const after = underpassTravel(gap('experience', -0.001, 1.5));
expect(before?.exit).toBe('experience');
expect(after?.exit).toBe('experience');
expect(Math.abs(after!.z - before!.z)).toBeLessThan(1);
expect(underpassTravel(gap('experience', -0.24, 1.26))!.z).toBeGreaterThan(after!.z);
expect(underpassTravel(gap('experience', -0.3, 1.2))).toBeNull();
// Contact has no next heading, but its own deck still trails out.
expect(underpassTravel(gap('contact', -0.1, null))?.exit).toBe('contact');
// Start has no deck behind it.
expect(underpassTravel(gap('start', 0, 1))).toBeNull();
});
});
describe('underpass shade', () => {
it('peaks at no more than 35% over the camera and is zero outside the 18 m band', () => {
let peak = 0;
for (let z = -200; z <= 80; z += 0.25) {
const shade = shadeFor(z, 25);
peak = Math.max(peak, shade);
expect(shade).toBeLessThanOrEqual(0.35);
if (Math.abs(z - 25) >= 18) expect(shade).toBe(0);
}
expect(peak).toBeCloseTo(SHADE_PEAK, 10);
expect(SHADE_PEAK).toBeLessThanOrEqual(0.35);
expect(shadeFor(Number.NaN)).toBe(0);
});
it('never moves faster than 0 to peak in 300 ms', () => {
let shade = 0;
let steps = 0;
while (shade < SHADE_PEAK) {
shade = slewShade(shade, 1, 1 / 120);
steps += 1;
}
expect(steps / 120).toBeGreaterThanOrEqual(0.3 - 1e-9);
expect(slewShade(SHADE_PEAK, 0, 0.1)).toBeCloseTo(SHADE_PEAK * (2 / 3), 10);
});
it('latches once per pass: brisk scrolls and explicit navigation get none, and full shades are 1 s apart', () => {
const latch = createShadeLatch();
expect(latch.update(true, 0.3, false, 0)).toBe(true);
// Held for the whole pass, even if the drive speeds up.
expect(latch.update(true, 0.9, false, 200)).toBe(true);
expect(latch.update(false, 0.3, false, 400)).toBe(false);
// The next pass within 1 s of the last full shade: none.
expect(latch.update(true, 0.3, false, 900)).toBe(false);
expect(latch.update(true, 0.3, false, 1_200)).toBe(false);
latch.update(false, 0.3, false, 1_300);
expect(latch.update(true, 0.3, false, 1_400)).toBe(true);
latch.update(false, 0.3, false, 1_500);
// Brisk: the sign's gate.
expect(latch.update(true, 0.51, false, 5_000)).toBe(false);
latch.update(false, 0.3, false, 5_100);
expect(latch.update(true, 0.5, false, 5_200)).toBe(true);
latch.update(false, 0.3, false, 5_300);
// An explicit navigation long-scroll.
expect(latch.update(true, 0.2, true, 9_000)).toBe(false);
expect(latch.passes).toBe(3);
});
});
describe('underpass exits', () => {
it('has seven decks, exits 02-08, each in its sign accent', () => {
expect(UNDERPASS_EXITS.map((exit) => exit.label)).toEqual([
'EXIT 02', 'EXIT 03', 'EXIT 04', 'EXIT 05', 'EXIT 06', 'EXIT 07', 'EXIT 08',
]);
for (const exit of UNDERPASS_EXITS) expect(exit.accent).toBe(SIGN_ACCENTS[exit.id]);
expect(UNDERPASS_EXITS[0]!.id).toBe('profile');
expect(UNDERPASS_EXITS[6]!.id).toBe('contact');
});
});
describe('underpass deck', () => {
it('clears the scaled car and the docked exit sign, and spans the road and its shoulders', () => {
const G = UNDERPASS_GEOMETRY;
expect(G.clearance).toBeGreaterThan(G.carHeight * 2);
// The docked sign's top edge reaches 20.4 m (Infrastructure at 1440x900).
expect(G.clearance).toBeGreaterThan(20.4);
// Road 20.4 m wide, shoulders to +-12; piers stand clear of both.
expect(G.span / 2).toBeGreaterThan(12);
expect(G.pierX - G.pierWidth / 2).toBeGreaterThan(12);
expect(G.pierX + G.pierWidth / 2).toBeLessThan(G.span / 2);
expect(G.depth).toBe(10);
expect(G.span).toBe(70);
});
it('returns shade 0 with no gap, and casts it only for a calm scroll on the full tier', () => {
const sound = vi.fn();
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
expect(world.update('experience', null, 0.2, 1 / 60, 0)).toEqual({ shade: 0, z: null, exit: null });
expect(world.update('experience', 0.3, 0.2, 1 / 60, 16)).toEqual({ shade: 0, z: null, exit: null });
expect(world.group.visible).toBe(false);
// Slow scroll to the line.
let now = 16;
let peak = 0;
for (let p = 0.5; p <= 1; p += 0.002) {
now += 33;
const state = world.update('experience', p, 0.2, 0.033, now, { cameraZ: 25 });
peak = Math.max(peak, state.shade);
expect(state.shade).toBeLessThanOrEqual(0.35);
}
expect(peak).toBeGreaterThan(0.3);
expect(world.inspect().exit).toBe('infrastructure');
expect(world.inspect().label).toBe('EXIT 04');
expect(world.inspect().pool).toBe(true);
expect(sound).toHaveBeenCalledTimes(1);
expect(underpassUniforms.uShade.value).toBeGreaterThan(0.3);
// Past the deck: the shade eases out over >= 300 ms.
const out = world.update('infrastructure', null, 0.2, 0.1, now + 100);
expect(out.shade).toBeGreaterThan(0.2);
for (let i = 0; i < 10; i += 1) world.update('infrastructure', null, 0.2, 0.1, now + 200 + i * 100);
expect(world.inspect().shade).toBe(0);
expect(underpassUniforms.uShade.value).toBe(0);
});
it('a brisk pass or an explicit navigation gets the structure but no shade or sound', () => {
for (const [speed, explicit] of [[0.8, false], [0.2, true]] as const) {
const sound = vi.fn();
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
let now = 0;
for (let p = 0.5; p <= 1; p += 0.01) {
now += 16;
const state = world.update('profile', p, speed, 0.016, now, { explicit });
expect(state.shade).toBe(0);
}
expect(world.inspect().visible).toBe(true);
expect(sound).not.toHaveBeenCalled();
}
});
it('the low tier draws the deck and strips only', () => {
const sound = vi.fn();
const world = createUnderpass(new SceneResourceBag(), { full: false, sound });
let now = 0;
for (let p = 0.5; p <= 1; p += 0.002) {
now += 33;
expect(world.update('toolkit', p, 0.2, 0.033, now).shade).toBe(0);
}
expect(world.inspect().visible).toBe(true);
expect(world.inspect().pool).toBe(false);
expect(sound).not.toHaveBeenCalled();
expect(underpassUniforms.uShade.value).toBe(0);
});
it('holds behind the exit sign while it comes in', () => {
const world = createUnderpass(new SceneResourceBag(), { full: true, sound: vi.fn() });
let now = 0;
for (let p = 0.6; p <= 0.95; p += 0.01) {
now += 16;
world.update('profile', p, 0.2, 0.016, now, { signZ: -60 });
expect(world.inspect().z!).toBeLessThanOrEqual(-70 + 1e-6);
}
expect(world.inspect().held).toBe(true);
});
});
describe('underpass palette boost', () => {
it('settles x4 deep in the shade, and not on top of the storm', () => {
expect(paletteDampRate(0)).toBe(1);
expect(paletteDampRate(0.5)).toBe(1);
expect(paletteDampRate(0.8)).toBe(4);
expect(paletteDampRate(0.8, 1)).toBe(1);
expect(paletteDampRate(0.8, 0.5)).toBeCloseTo(2.5, 10);
const slow = createMutableChapterPalette('deep-night');
const fast = createMutableChapterPalette('deep-night');
const target = writeChapterPalette(createMutableChapterPalette(), 'deep-night', 'infrastructure', 1);
dampPalette(slow, target, 0.1);
dampPalette(fast, target, 0.1, 4);
const distance = (palette: typeof slow) => Math.abs(palette.warmth - target.warmth);
expect(distance(fast)).toBeLessThan(distance(slow));
});
});
describe('underpass pass swell', () => {
it('is silent with no audible soundtrack', () => {
expect(readSwitchboard()).toBeNull();
expect(playUnderpassPass()).toBe(false);
});
it('stays out of the initial bundle: only the scene chunk imports the helper', () => {
const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8');
expect(environment).toContain("from '@/scene/world/underpass'");
const css = readFileSync(resolve(process.cwd(), 'src/styles/world.css'), 'utf8');
expect(css).toContain("html[data-underpass='under'] .journey-hud__section");
});
});