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