feat: route log odometer and lit mile markers for profile and experience

This commit is contained in:
2026-10-11 06:15:36 +03:00
parent b6e22a1299
commit 19f3ce79e6
29 changed files with 932 additions and 26 deletions

View File

@@ -163,6 +163,16 @@ describe('the 03:12 window on the server', () => {
});
});
describe('MILE MARKERS on the server', () => {
it('ships no route log: no attribute, no odometer and an empty sub-line', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-route-log');
expect(html).not.toContain('route-odo');
expect(html).not.toContain('journey-hud__log-pulse');
expect(html).not.toMatch(/LOG \d\d\//u);
});
});
describe('HIGH BEAM on the server', () => {
it('ships no beam state: the name renders fully lit and its text is unchanged', async () => {
const html = await render('/', validInputs);

View File

@@ -448,4 +448,32 @@ describe("JourneyHud", () => {
expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12");
window.unmount();
});
it("rolls the ROUTE LOG index on odometer digits keyed by value", async () => {
const hud = mount(JourneyHud, {
props: {
journey: journey({ activeSectionId: "experience" }),
sectionLabel: "Experience",
subline: "LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218",
},
});
const subline = hud.get(".journey-hud__subline");
expect(subline.text()).toBe("LOG 01/04 \u00b7 AUG 2025 \u2192 PRESENT \u00b7 TRUPA LUI R\u0102ZU\u0218");
expect(hud.findAll(".route-odo__digit").map((digit) => digit.text())).toEqual(["0", "1"]);
const tens = hud.findAll(".route-odo__digit")[0]!.element;
const ones = hud.findAll(".route-odo__digit")[1]!.element;
const pulse = hud.get(".journey-hud__log-pulse").element;
await hud.setProps({ subline: "LOG 02/04 \u00b7 JUN 2025 \u2192 AUG 2025 \u00b7 CCF \u00b7 REWIND" });
const digits = hud.findAll(".route-odo__digit");
expect(digits.map((digit) => digit.text())).toEqual(["0", "2"]);
expect(digits[0]!.element).toBe(tens);
expect(digits[1]!.element).not.toBe(ones);
expect(hud.get(".journey-hud__log-pulse").element).not.toBe(pulse);
expect(hud.get(".journey-hud__subline").attributes("data-rewind")).toBe("true");
await hud.setProps({ subline: "" });
expect(hud.findAll(".route-odo").length).toBe(0);
expect(hud.find(".journey-hud__log-pulse").exists()).toBe(false);
expect(hud.get(".journey-hud__subline").text()).toBe("");
hud.unmount();
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import {
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('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();
});
});

View File

@@ -0,0 +1,187 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { useRouteLog } from '@/composables/useRouteLog';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import {
createRewindLatch,
fitCaption,
milestoneIndex,
milestonesFor,
parseDates,
passPoint,
ROUTE_LOG_BUDGET,
routeLogSubline,
sectionLocalProgress,
} from '@/journey/route-log';
import type { JourneySnapshot } from '@/journey/types';
import type { PortfolioContent, SectionId } from '@/content/types';
const content = JSON.parse(
readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'),
) as PortfolioContent;
const at = (activeSectionId: SectionId, active: number, next: number | null) => ({
activeSectionId,
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: active, nextHeadingOffsetVh: next },
});
describe('section-local progress', () => {
it('runs 0..1 between the chapter heading and the next, null elsewhere', () => {
expect(sectionLocalProgress(at('experience', 0, 2), 'experience')).toBe(0);
expect(sectionLocalProgress(at('experience', -1, 1), 'experience')).toBeCloseTo(0.5);
expect(sectionLocalProgress(at('experience', -3, -1), 'experience')).toBe(1);
expect(sectionLocalProgress(at('experience', -1, 1), 'profile')).toBeNull();
expect(sectionLocalProgress(at('profile', 0, null), 'profile')).toBeNull();
expect(sectionLocalProgress(at('profile', 1, 1), 'profile')).toBeNull();
expect(sectionLocalProgress(at('profile', Number.NaN, 1), 'profile')).toBeNull();
});
it('still backs the 03:12 window', () => {
expect(maintenanceProgress(at('infrastructure', -1, 1))).toBeCloseTo(0.5);
expect(maintenanceProgress(at('experience', -1, 1))).toBeNull();
});
});
describe('milestones from the real content', () => {
it('lists three roles and the systems plate for Experience', () => {
const items = milestonesFor(content, 'experience');
expect(items).toHaveLength(4);
expect(items.map((item) => item.index)).toEqual([0, 1, 2, 3]);
expect(items[0]).toMatchObject({ dateLabel: 'AUG 2025 → PRESENT', compactDate: '2025→PRESENT', caption: 'TRUPA LUI RĂZUȘ' });
expect(items[1]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', compactDate: 'JUN→AUG 2025' });
expect(items[2]).toMatchObject({ dateLabel: 'JUN 2025 → AUG 2025', caption: 'ISENSE SOLUTIONS' });
expect(items[3]).toMatchObject({ dateLabel: '', caption: 'SYSTEMS OPERATIONS' });
});
it('lists the three screening facts for Profile', () => {
const items = milestonesFor(content, 'profile');
expect(items.map((item) => item.caption)).toEqual(['EDUCATION', 'LANGUAGES', 'CERTIFICATIONS']);
expect(items.map((item) => item.dateLabel)).toEqual(['2023 → 2026', '', '2022 · 2025']);
});
it('has no posts in other chapters', () => {
expect(milestonesFor(content, 'toolkit')).toEqual([]);
expect(routeLogSubline(0.5, milestonesFor(content, 'toolkit'))).toBeNull();
expect(routeLogSubline(0.5, milestonesFor(content, 'experience').slice(0, 1))).toBeNull();
});
it('parses spans, open spans and year lists', () => {
expect(parseDates('Jan 2024 - Present')).toEqual({ dateLabel: 'JAN 2024 → PRESENT', compactDate: '2024→PRESENT' });
expect(parseDates('2019–2021')).toEqual({ dateLabel: '2019 → 2021', compactDate: '2019→2021' });
expect(parseDates('Remote')).toEqual({ dateLabel: '', compactDate: '' });
});
});
describe('the ROUTE LOG sub-line', () => {
const experience = milestonesFor(content, 'experience');
const profile = milestonesFor(content, 'profile');
it('steps through the posts at the middle of each share', () => {
expect(passPoint(0, 4)).toBe(0.125);
expect(passPoint(3, 4)).toBe(0.875);
expect(milestoneIndex(0, 4)).toBe(0);
expect(milestoneIndex(0.26, 4)).toBe(1);
expect(milestoneIndex(1, 4)).toBe(3);
expect(routeLogSubline(0.3, experience)).toBe('LOG 02/04 · JUN 2025 → AUG 2025 · CCF');
expect(routeLogSubline(0.6, experience)).toBe('LOG 03/04 · JUN 2025 → AUG 2025 · ISENSE SOLUTIONS');
expect(routeLogSubline(0.95, experience)).toBe('LOG 04/04 · SYSTEMS OPERATIONS');
expect(routeLogSubline(0.4, profile)).toBe('LOG 02/03 · LANGUAGES');
expect(routeLogSubline(0.1, profile)).toBe('LOG 01/03 · 2023 → 2026 · EDUCATION');
});
it('adds REWIND while scrolling back', () => {
expect(routeLogSubline(0.6, experience, true)).toMatch(/^LOG 03\/04 · JUN 2025 → AUG 2025 · .+ · REWIND$/u);
// A narrow plate rewinding has no room for the date: the index and caption stay.
expect(routeLogSubline(0.1, experience, true, 'compact')).toBe('LOG 01/04 · TLR · REWIND');
});
it('fits the narrow plates (phones) in the budget, shortening the organisation', () => {
for (const items of [experience, profile]) {
for (let p = 0; p <= 1; p += 0.05) {
for (const reversing of [false, true]) {
expect(routeLogSubline(p, items, reversing, 'compact')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.compact);
expect(routeLogSubline(p, items, reversing, 'wide')!.length).toBeLessThanOrEqual(ROUTE_LOG_BUDGET.wide);
}
}
}
expect(routeLogSubline(0.1, experience, false, 'compact')).toBe('LOG 01/04 · 2025→PRESENT · TLR');
// Only 'IS' would fit after the date, so the caption takes the date's place.
expect(routeLogSubline(0.6, experience, false, 'compact')).toBe('LOG 03/04 · ISENSE SOLUTIONS');
expect(fitCaption('CAMERA CONSULTANȚILOR FISCALI', 10)).toBe('CCF');
expect(fitCaption('ISENSE SOLUTIONS', 2)).toBe('IS');
expect(fitCaption('CERTIFICATIONS', 4)).toBe('');
});
it('latches a rewind while progress falls, per chapter', () => {
const latch = createRewindLatch();
expect(latch(0.2, 'experience')).toBe(false);
expect(latch(0.4, 'experience')).toBe(false);
expect(latch(0.3, 'experience')).toBe(true);
expect(latch(0.3005, 'experience')).toBe(true);
expect(latch(0.5, 'experience')).toBe(false);
// Profile ends at 1 and Experience starts at 0: a new chapter is not a rewind.
expect(latch(1, 'profile')).toBe(false);
expect(latch(0, 'experience')).toBe(false);
expect(latch(0.1, 'experience')).toBe(false);
expect(latch(null)).toBe(false);
});
});
describe('useRouteLog', () => {
const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0.3,
direction: 0,
acceleration: 0,
driveSpeed: 0,
activeSectionId,
chapter: 'systems',
sign: null,
arrived: false,
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n },
});
it('sets html[data-route-log] and the sub-line only in its chapters, then cleans up', async () => {
const journey = shallowRef(snapshot('start', -1, 1));
let subline = shallowRef('');
const wrapper = mount(defineComponent({
setup() {
subline = useRouteLog(journey, () => content) as typeof subline;
return () => h('span', subline.value);
},
}));
const root = document.documentElement;
await nextTick();
expect(root.dataset.routeLog).toBeUndefined();
expect(subline.value).toBe('');
journey.value = snapshot('experience', -0.2, 1.8);
await nextTick();
expect(root.dataset.routeLog).toBe('1/4');
expect(subline.value).toMatch(/^LOG 01\/04 · /u);
journey.value = snapshot('experience', -1.2, 0.8);
await nextTick();
expect(root.dataset.routeLog).toBe('3/4');
expect(subline.value).not.toMatch(/REWIND/u);
journey.value = snapshot('experience', -0.8, 1.2);
await nextTick();
expect(root.dataset.routeLog).toBe('2/4');
expect(subline.value).toMatch(/^LOG 02\/04 · .* · REWIND$/u);
journey.value = snapshot('infrastructure', -0.5, 1.5);
await nextTick();
expect(root.dataset.routeLog).toBeUndefined();
expect(subline.value).toBe('');
journey.value = snapshot('profile', -1, 1);
await nextTick();
expect(root.dataset.routeLog).toBe('2/3');
wrapper.unmount();
expect(root.dataset.routeLog).toBeUndefined();
});
});