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"); }); });