import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import type { SectionId } from '@/content/types'; import { SceneResourceBag } from '@/scene/resources'; import { createMilestoneRoad, createMilestones, MILESTONE_ASSIGN_Z, MILESTONE_FAR_Z, MILESTONE_FLASH_MAX_RATE, MILESTONE_NEAR_Z, MILESTONE_PASS_Z, MILESTONE_SIDE, MILESTONE_SLOTS, MILESTONE_SPACING, milestoneEntries, milestoneRangeFade, milestoneSlot, type MilestonePost, } from '@/scene/world/milestones'; import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log'; const DT = 1 / 60; /** Drives the road for `seconds`: progress and road speed (m/s) per frame. */ function drive( road: ReturnType, seconds: number, frame: (t: number) => { section: SectionId; p: number | null; speed: number }, start = { distance: 0 }, each?: (posts: readonly MilestonePost[], t: number, p: number | null) => void, ) { for (let i = 0; i <= Math.round(seconds / DT); i += 1) { const t = i * DT; const { section, p, speed } = frame(t); start.distance += speed * DT; const posts = road.step({ section, p, distance: start.distance, pulse: 0, deltaSeconds: DT }); each?.(posts, t, p); } return start; } /** Progress at which each feature post (by entry) went past the pass line. */ function passes( road: ReturnType, seconds: number, frame: (t: number) => { section: SectionId; p: number | null; speed: number }, ) { const seen: Array<{ entry: number; p: number | null; t: number }> = []; let last: readonly number[] = []; drive(road, seconds, frame, { distance: 0 }, (posts, t, p) => { posts.forEach((post, k) => { if (post.entry >= 0 && post.presence > 0 && (last[k] ?? Infinity) < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) { seen.push({ entry: post.entry, p, t }); } }); last = posts.map((post) => post.z); }); return seen; } describe('mile marker lattice', () => { it('spaces the posts evenly along the road, inside the drawn range', () => { expect(MILESTONE_NEAR_Z - MILESTONE_FAR_Z).toBe(MILESTONE_SPACING * MILESTONE_SLOTS); for (const distance of [0, 17.3, 59.99, 60, 1234.5, 98765.4]) { const zs = Array.from({ length: MILESTONE_SLOTS }, (_, k) => milestoneSlot(distance, k).z).sort((a, b) => a - b); for (const z of zs) { expect(z).toBeGreaterThanOrEqual(MILESTONE_FAR_Z); expect(z).toBeLessThan(MILESTONE_NEAR_Z); } for (let i = 1; i < zs.length; i += 1) expect(zs[i]! - zs[i - 1]!).toBeCloseTo(MILESTONE_SPACING, 6); } // A post is born in the haze and leaves behind the camera, never in view. expect(milestoneRangeFade(MILESTONE_FAR_Z)).toBe(0); expect(milestoneRangeFade(MILESTONE_NEAR_Z - 0.01)).toBe(0); expect(milestoneRangeFade(MILESTONE_PASS_Z)).toBe(1); }); it('moves every post with the travel distance, metre for metre, and numbers them along the route', () => { for (let k = 0; k < MILESTONE_SLOTS; k += 1) { const a = milestoneSlot(1000, k); const b = milestoneSlot(1007, k); if (b.id === a.id) expect(b.z - a.z).toBeCloseTo(7, 6); else expect(b.id).toBeGreaterThan(a.id); } // The farther post is the later one on the route. const now = Array.from({ length: MILESTONE_SLOTS }, (_, k) => milestoneSlot(500, k)).sort((x, y) => x.z - y.z); for (let i = 1; i < now.length; i += 1) expect(now[i]!.id).toBeLessThan(now[i - 1]!.id); }); }); describe('mile markers on the road', () => { it('keep streaming past while the reader is still (constant progress)', () => { const road = createMilestoneRoad(true); const state = drive(road, 2, () => ({ section: 'experience', p: 0.42, speed: 18 })); const before = road.posts().map((post) => ({ ...post })); drive(road, 1, () => ({ section: 'experience', p: 0.42, speed: 18 }), state); const after = road.posts(); let moved = 0; after.forEach((post, k) => { const was = before[k]!; // Each post either moved 18 m toward the camera or wrapped to the far end. if (post.z > was.z) { expect(post.z - was.z).toBeCloseTo(18 + 18 * DT, 1); moved += 1; } else { expect(post.z).toBeLessThan(MILESTONE_FAR_Z + 20); } }); expect(moved).toBeGreaterThanOrEqual(MILESTONE_SLOTS - 1); // Visible plain posts on the free shoulder. const shown = after.filter((post) => post.presence > 0.5); expect(shown.length).toBeGreaterThanOrEqual(2); expect(shown.every((post) => post.x === MILESTONE_SIDE.experience)).toBe(true); }); it('stands on the free shoulder: right in Experience, left in Profile, nowhere else', () => { expect(MILESTONE_SIDE.experience).toBeGreaterThan(8); expect(MILESTONE_SIDE.profile).toBeLessThan(-8); const road = createMilestoneRoad(true); const state = drive(road, 3, () => ({ section: 'profile', p: 0.5, speed: 30 })); expect(road.posts().filter((post) => post.presence > 0).every((post) => post.x === MILESTONE_SIDE.profile)).toBe(true); drive(road, 1.5, () => ({ section: 'infrastructure', p: null, speed: 30 }), state); expect(road.posts().every((post) => post.presence === 0)).toBe(true); const start = createMilestoneRoad(true); drive(start, 3, () => ({ section: 'start', p: null, speed: 30 })); expect(start.posts().every((post) => post.presence === 0)).toBe(true); }); it('fades in and out over time at chapter changes instead of popping', () => { const road = createMilestoneRoad(true); const state = drive(road, 3, () => ({ section: 'start', p: null, speed: 18 })); let first = -1; drive(road, 0, () => ({ section: 'profile', p: 0.01, speed: 18 }), state, (posts) => { first = Math.max(...posts.map((post) => post.presence)); }); expect(first).toBeLessThan(0.2); drive(road, 1.2, () => ({ section: 'profile', p: 0.05, speed: 18 }), state); expect(Math.max(...road.posts().map((post) => post.presence))).toBeGreaterThan(0.8); // Into Experience: Profile's posts fade (no frame drops more than ~10%). let previous = road.posts().map((post) => post.presence); let biggestDrop = 0; drive(road, 1, () => ({ section: 'experience', p: 0.01, speed: 18 }), state, (posts) => { posts.forEach((post, k) => { if (post.x === MILESTONE_SIDE.profile) biggestDrop = Math.max(biggestDrop, previous[k]! - post.presence); }); previous = posts.map((post) => post.presence); }); expect(biggestDrop).toBeLessThan(0.12); expect(road.posts().filter((post) => post.presence > 0).every((post) => post.x === MILESTONE_SIDE.experience)).toBe(true); }); it('sends each log entry past the camera about when its plate crosses the line (reading pace)', () => { for (const [section, rate, speed] of [ ['experience', 0.025, 40], ['experience', 0.012, 18], ['profile', 0.03, 60], ] as const) { const count = MILESTONE_COUNTS[section]; const road = createMilestoneRoad(true); const seen = passes(road, 1 / rate, (t) => ({ section, p: Math.min(1, t * rate), speed })); expect(seen.map((pass) => pass.entry)).toEqual(Array.from({ length: count }, (_, i) => i)); for (const pass of seen) { expect(Math.abs(pass.p! - passPoint(pass.entry, count))).toBeLessThan(0.3 / count); } } }); it('sends the entry the reader stopped on, once, and keeps the shoulder moving', () => { const road = createMilestoneRoad(true); // A jump that lands early in entry 2 of 4, then nothing. const p = passPoint(2, 4) - 0.08; const seen = passes(road, 40, (t) => ({ section: 'experience', p: t < 0.3 ? (t / 0.3) * p : p, speed: 18 })); expect(seen.map((pass) => pass.entry)).toEqual([2]); expect(seen[0]!.t).toBeLessThan(15); }); it('runs the road forward when scrolling back: rewound entries come up the road again', () => { const road = createMilestoneRoad(true); const rate = 0.03; const back = 1 / rate; const progress = (t: number) => (t < back ? t * rate : Math.max(0, 1 - (t - back) * rate)); const seen: Array<{ entry: number; reverse: boolean }> = []; let last: number[] = []; let ids: number[] = []; drive(road, back * 2, (t) => ({ section: 'experience', p: progress(t), speed: 30 }), { distance: 0 }, (posts, t) => { posts.forEach((post, k) => { // Never toward the horizon, except the wrap to the far end (a new post). if (last[k] !== undefined && ids[k] === post.number) expect(post.z).toBeGreaterThanOrEqual(last[k]!); if (post.entry >= 0 && post.presence > 0 && (last[k] ?? Infinity) < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) { seen.push({ entry: post.entry, reverse: t >= back }); } }); last = posts.map((post) => post.z); ids = posts.map((post) => post.number); }); expect(seen.filter((pass) => !pass.reverse).map((pass) => pass.entry)).toEqual([0, 1, 2, 3]); // The last entry has only just gone by; the others come back in reverse order. expect(seen.filter((pass) => pass.reverse).map((pass) => pass.entry)).toEqual([2, 1, 0]); }); it('only hands an entry to a post still far up the road, in the haze', () => { const road = createMilestoneRoad(true); const born = new Map(); drive(road, 30, (t) => ({ section: 'experience', p: Math.min(1, t * 0.03), speed: 30 }), { distance: 0 }, (posts) => { posts.forEach((post) => { if (post.entry >= 0 && !born.has(post.number)) born.set(post.number, post.z); }); }); expect(born.size).toBeGreaterThan(0); for (const z of born.values()) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z); }); }); describe('mile marker light', () => { it('warms a feature lamp as it approaches and leaves plain posts dark', () => { const road = createMilestoneRoad(true); const lamps = new Map>(); drive(road, 20, () => ({ section: 'experience', p: passPoint(1, 4) - 0.1, speed: 18 }), { distance: 0 }, (posts) => { for (const post of posts) { if (post.entry < 0) expect(post.lamp).toBe(0); else if (post.presence > 0) lamps.set(post.number, [...(lamps.get(post.number) ?? []), { z: post.z, lamp: post.lamp }]); } }); const track = [...lamps.values()][0]!; const approach = track.filter((sample) => sample.z < MILESTONE_PASS_Z); for (let i = 1; i < approach.length; i += 1) expect(approach[i]!.lamp).toBeGreaterThanOrEqual(approach[i - 1]!.lamp); expect(approach.at(-1)!.lamp).toBeGreaterThan(0.8); }); it('glints once as a feature post passes, never twice inside 0.6 s, and never during a jump', () => { const road = createMilestoneRoad(true); const count = MILESTONE_COUNTS.experience; let glints: number[] = []; let previous = 0; drive(road, 1 / 0.02, (t) => ({ section: 'experience', p: Math.min(1, t * 0.02), speed: 30 }), { distance: 0 }, (posts, t) => { const level = Math.max(...posts.map((post) => post.glint)); if (level === 1 && previous < 1) glints.push(t); previous = level; }); expect(glints.length).toBe(count); for (let i = 1; i < glints.length; i += 1) expect(glints[i]! - glints[i - 1]!).toBeGreaterThanOrEqual(0.6); // A navigator jump: the whole chapter in 0.6 s at full road speed. const jump = createMilestoneRoad(true); glints = []; let peak = 0; const state = drive(jump, 4, () => ({ section: 'profile', p: 0.02, speed: 18 })); drive(jump, 0.6, (t) => ({ section: 'experience', p: Math.min(1, t / 0.6), speed: 120 }), state, (posts) => { peak = Math.max(peak, ...posts.map((post) => post.glint)); }); expect(peak).toBe(0); expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5); }); it('low tier and touch: no glint and no pulse swell', () => { const road = createMilestoneRoad(false); let peak = 0; let lamp = 0; for (let i = 0; i < 60 * 30; i += 1) { const posts = road.step({ section: 'experience', p: Math.min(1, i / (60 * 30)), distance: i * 0.5, pulse: 1, deltaSeconds: DT }); peak = Math.max(peak, ...posts.map((post) => post.glint)); lamp = Math.max(lamp, ...posts.map((post) => post.lamp)); } expect(peak).toBe(0); expect(lamp).toBeGreaterThan(0.5); expect(lamp).toBeLessThanOrEqual(1); }); }); describe('mile marker mesh', () => { it('draws the lattice in one mesh, visible only in Experience and Profile', () => { const resources = new SceneResourceBag(); const markers = createMilestones(resources, true); expect(markers.mesh.count).toBe(MILESTONE_SLOTS); expect(markers.pool.count).toBe(MILESTONE_SLOTS); markers.update({ section: 'start', p: null, distance: 0, pulse: 0, deltaSeconds: DT }); expect(markers.mesh.visible).toBe(false); expect(markers.pool.visible).toBe(false); let featured = false; let pooled = false; for (let i = 1; i < 60 * 12; i += 1) { markers.update({ section: 'experience', p: 0.4, distance: i * 0.3, pulse: 0.5, deltaSeconds: DT }); featured ||= markers.posts().some((post) => post.entry >= 0); pooled ||= markers.pool.visible; } expect(markers.mesh.visible).toBe(true); expect(featured).toBe(true); expect(pooled).toBe(true); for (let i = 0; i < 60; i += 1) { markers.update({ section: 'infrastructure', p: null, distance: 300 + i * 0.3, pulse: 0, deltaSeconds: DT }); } expect(markers.mesh.visible).toBe(false); expect(markers.pool.visible).toBe(false); resources.dispose(); }); it('never lights pools on the low tier', () => { const resources = new SceneResourceBag(); const markers = createMilestones(resources, false); let pooled = false; let featured = false; for (let i = 1; i < 60 * 15; i += 1) { markers.update({ section: 'profile', p: 0.5, distance: i * 0.3, pulse: 1, deltaSeconds: DT }); pooled ||= markers.pool.visible; featured ||= markers.posts().some((post) => post.entry >= 0 && post.lamp > 0.5); } expect(markers.mesh.visible).toBe(true); expect(featured).toBe(true); expect(pooled).toBe(false); resources.dispose(); }); }); describe('mile marker plates', () => { it('letters the feature plates from the inert input snapshot, and survives without one', () => { const content = JSON.parse(readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8')) as unknown; const page = document.implementation.createHTMLDocument('plates'); const script = page.createElement('script'); script.id = 'portfolio-inputs'; script.type = 'application/json'; script.textContent = JSON.stringify({ content }); page.body.append(script); const items = milestoneEntries('experience', page); expect(items).toHaveLength(MILESTONE_COUNTS.experience); expect(items[1]).toMatchObject({ compactDate: 'JUN→AUG 2025', caption: 'CAMERA CONSULTANȚILOR FISCALI' }); expect(milestoneEntries('profile', page)).toHaveLength(MILESTONE_COUNTS.profile); script.textContent = '{not json'; expect(milestoneEntries('experience', page)).toEqual([]); expect(milestoneEntries('experience', document.implementation.createHTMLDocument('none'))).toEqual([]); }); });