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

133 lines
6.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import {
MILESTONE_FLASH_MAX_RATE,
createMilestones,
MILESTONE_FAR_Z,
MILESTONE_NEAR_Z,
MILESTONE_PASS_Z,
MILESTONE_SIDE,
milestoneLayout,
milestoneZ,
} from '@/scene/world/milestones';
import { MILESTONE_COUNTS, passPoint } from '@/journey/route-log';
const sweep = (from: number, to: number, steps = 200): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
describe('mile marker layout', () => {
it('rolls every post toward the camera monotonically with progress, in order', () => {
for (const section of ['experience', 'profile'] as const) {
const count = MILESTONE_COUNTS[section];
for (let i = 0; i < count; i += 1) {
let previous = Number.NEGATIVE_INFINITY;
for (const p of sweep(0, 1)) {
const z = milestoneZ(p, i, count);
expect(z).toBeGreaterThan(previous);
previous = z;
}
// At the camera's near edge exactly as its plate crosses the line.
expect(milestoneZ(passPoint(i, count), i, count)).toBeCloseTo(MILESTONE_PASS_Z);
expect(MILESTONE_PASS_Z).toBeCloseTo((MILESTONE_FAR_Z + MILESTONE_NEAR_Z) / 2);
// Still on the near side of the camera (z ~25) and large in frame.
expect(MILESTONE_PASS_Z).toBeLessThan(20);
// Earlier posts are always nearer.
if (i > 0) expect(milestoneZ(0.5, i - 1, count)).toBeGreaterThan(milestoneZ(0.5, i, count));
}
}
});
it('stands on the free shoulder: right of the road in Experience, left in Profile', () => {
expect(MILESTONE_SIDE.experience).toBeGreaterThan(8);
expect(MILESTONE_SIDE.profile).toBeLessThan(-8);
const posts = milestoneLayout('experience', 0.5).filter((post) => post.presence > 0);
expect(posts.length).toBeGreaterThan(0);
expect(posts.every((post) => post.section === 'experience' && post.x === MILESTONE_SIDE.experience)).toBe(true);
});
it('is hidden outside its chapters and fades at the chapter edges', () => {
for (const section of ['start', 'infrastructure', 'toolkit', 'contact'] as const) {
expect(milestoneLayout(section, 0.5).every((post) => post.presence === 0)).toBe(true);
}
expect(milestoneLayout('experience', null).every((post) => post.presence === 0)).toBe(true);
expect(milestoneLayout('experience', 0).every((post) => post.presence === 0)).toBe(true);
expect(milestoneLayout('profile', 1).every((post) => post.presence === 0)).toBe(true);
const inside = milestoneLayout('profile', 0.5);
expect(inside.filter((post) => post.section === 'experience').every((post) => post.presence === 0)).toBe(true);
expect(inside.some((post) => post.section === 'profile' && post.presence === 1)).toBe(true);
});
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
const ps = sweep(0, 1);
const forward = ps.map((p) => milestoneLayout('experience', p));
const backward = [...ps].reverse().map((p) => milestoneLayout('experience', p)).reverse();
expect(backward).toEqual(forward);
});
});
describe('mile marker mesh', () => {
it('draws seven instances in one mesh, visible only in Experience and Profile', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
expect(markers.mesh.count).toBe(7);
expect(markers.pool.count).toBe(7);
markers.update('start', null, 0, 1 / 60);
expect(markers.mesh.visible).toBe(false);
expect(markers.pool.visible).toBe(false);
markers.update('experience', 0.4, 0.5, 1 / 60);
expect(markers.mesh.visible).toBe(true);
expect(markers.pool.visible).toBe(true);
markers.update('infrastructure', null, 0, 1 / 60);
expect(markers.mesh.visible).toBe(false);
expect(markers.pool.visible).toBe(false);
resources.dispose();
});
it('flashes a post as its plate crosses the line, either way, and decays in 300 ms', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
const second = passPoint(1, 4);
markers.update('experience', second - 0.02, 0, 1 / 60);
markers.update('experience', second - 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(0);
markers.update('experience', second + 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(1);
for (let i = 0; i < 20; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(0);
// Back the other way (after the 0.6 s gap) flashes again.
for (let i = 0; i < 30; i += 1) markers.update('experience', second + 0.01, 0, 1 / 60);
markers.update('experience', second - 0.01, 0, 1 / 60);
expect(markers.flashes()[1]).toBe(1);
resources.dispose();
});
it('does not flash posts swept past by a navigator jump or a fling', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, true);
// A navigator long-scroll crosses the whole chapter in about 0.6 s.
let peak = 0;
for (let frame = 0; frame <= 36; frame += 1) {
markers.update('experience', frame / 36, 0, 1 / 60);
peak = Math.max(peak, ...markers.flashes());
}
expect(peak).toBe(0);
// Parked, then a single wheel tick at reading pace still flashes the post it crosses.
for (let i = 0; i < 90; i += 1) markers.update('experience', passPoint(3, 4) + 0.01, 0, 1 / 60);
markers.update('experience', passPoint(3, 4) - 0.01, 0, 1 / 60);
expect(markers.flashes()[3]).toBe(1);
expect(MILESTONE_FLASH_MAX_RATE).toBeGreaterThan(0.5);
resources.dispose();
});
it('never flashes on the low tier or touch', () => {
const resources = new SceneResourceBag();
const markers = createMilestones(resources, false);
const first = passPoint(0, 3);
markers.update('profile', first - 0.01, 1, 1 / 60);
markers.update('profile', first + 0.01, 1, 1 / 60);
expect(markers.flashes().every((level) => level === 0)).toBe(true);
expect(markers.mesh.visible).toBe(true);
resources.dispose();
});
});