feat: route log odometer and lit mile markers for profile and experience
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
113
tests/unit/milestones.test.ts
Normal file
113
tests/unit/milestones.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
187
tests/unit/route-log.test.ts
Normal file
187
tests/unit/route-log.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user