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

333 lines
15 KiB
TypeScript

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<typeof createMilestoneRoad>,
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<typeof createMilestoneRoad>,
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<number, number>();
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<number, Array<{ z: number; lamp: number }>>();
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([]);
});
});