feat: relay station masts come online through the toolkit chapter

This commit is contained in:
2026-10-11 08:32:33 +03:00
parent 568b0802b4
commit 33585f7e34
22 changed files with 935 additions and 2 deletions

View File

@@ -180,6 +180,16 @@ describe('DRIVE-IN on the server', () => {
});
});
describe('RELAY STATION on the server', () => {
it('ships no relay state: the toolkit groups render unchanged', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-relay');
expect(html).not.toContain('ONLINE');
const document = await renderedDocument();
expect(document.querySelectorAll('#toolkit .toolkit-group').length).toBe(validInputs.content.toolkit.length);
});
});
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

@@ -0,0 +1,158 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { BEAM_INTENSITY, createRelayMasts, RUNG_FLASH_GAP_SECONDS, relayLayout } from '@/scene/world/relay-masts';
import { publishRelay } from '@/journey/relay-progress';
import { readSwitchboard } from '@/audio/music-pulse';
import { playRelayTone } from '@/audio/relay-tone';
import type { PortfolioContent, SectionId } from '@/content/types';
const content = JSON.parse(
readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'),
) as PortfolioContent;
const counts = content.toolkit.map((group) => group.technologies.length);
afterEach(() => publishRelay([]));
const station = (full: boolean) => {
publishRelay(counts);
const tone = vi.fn();
return { world: createRelayMasts(new SceneResourceBag(), full, tone), tone };
};
describe('relay mast layout', () => {
it('stands four big masts on the far shoulders, alternating sides, first on the right', () => {
const masts = relayLayout(0.5, counts);
expect(masts).toHaveLength(4);
masts.forEach((mast, i) => {
expect(Math.abs(mast.x)).toBeGreaterThanOrEqual(50);
expect(mast.height).toBeGreaterThanOrEqual(40);
expect(Math.sign(mast.x)).toBe(i % 2 === 0 ? 1 : -1);
expect(mast.z).toBeLessThanOrEqual(-40);
expect(mast.z).toBeGreaterThanOrEqual(-130);
});
expect(masts.map((mast) => mast.rungs.length)).toEqual(counts);
expect(BEAM_INTENSITY).toBeLessThanOrEqual(0.8);
});
it('keeps the pair on each side apart and inside the frame, on wide and narrow lenses', () => {
for (const aspect of [1.6, 1024 / 700]) {
const masts = relayLayout(0.5, counts, true, aspect);
const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * aspect;
// Screen x of a mast's centre as a fraction of the half-width, and its lamp half-span in the same units.
const frame = (mast: (typeof masts)[number]) => {
const distance = 25 - mast.z;
const unit = 1 / (tanHalfWidth * distance);
return { centre: Math.abs(mast.x) * unit, span: mast.rungs[0]!.halfWidth * 1.15 * unit };
};
const near = frame(masts[0]!);
const far = frame(masts[2]!);
expect(near.centre + near.span).toBeLessThanOrEqual(1);
expect(far.centre - far.span).toBeGreaterThanOrEqual(0.84);
expect(far.centre + far.span).toBeLessThan(near.centre - near.span);
}
});
it('is absent outside the chapter and fades at its edges', () => {
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(1, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(0.5, counts).every((mast) => mast.presence === 1)).toBe(true);
});
});
describe('relay masts world', () => {
it('draws one rung instance per technology for the current content', () => {
const { world } = station(true);
world.update('toolkit', 0.5, 0, undefined, 0.016, 0);
expect(world.rungs()).toHaveLength(26);
expect(world.group.visible).toBe(true);
});
it('is hidden outside Toolkit', () => {
const { world } = station(true);
for (const section of ['start', 'selected-builds', 'contact'] as SectionId[]) {
world.update(section, 0.5, 0, undefined, 0.016, 0);
expect(world.group.visible).toBe(false);
}
world.update('toolkit', null, 0, undefined, 0.016, 0);
expect(world.group.visible).toBe(false);
});
it('lights rungs in order with scroll and unlights them in reverse', () => {
const { world } = station(true);
world.update('toolkit', 0.3, 0, undefined, 0.016, 0);
const lit = world.rungs().map((rung) => rung.lit);
expect(lit.slice(0, 11).every((v) => v === 1)).toBe(true);
expect(lit.slice(19).every((v) => v === 0)).toBe(true);
world.update('toolkit', 0.1, 0, undefined, 0.016, 16);
expect(world.rungs().slice(11).every((rung) => rung.lit === 0)).toBe(true);
});
it('chirps once per mast coming online, never on a jump into the chapter', () => {
const { world, tone } = station(true);
world.update('contact', null, 0, undefined, 0.016, 0);
world.update('toolkit', 0.5, 0, undefined, 0.016, 16);
expect(tone).not.toHaveBeenCalled();
world.update('toolkit', 0.6, 0, undefined, 0.016, 32);
expect(tone).toHaveBeenCalledTimes(1);
world.update('toolkit', 0.3, 0, undefined, 0.016, 48);
expect(tone).toHaveBeenCalledTimes(1);
});
it('flashes a hovered rung at most every 0.35 s', () => {
const { world } = station(true);
world.update('toolkit', 0.5, 0, undefined, 0.016, 0);
world.update('toolkit', 0.5, 0, '0:2', 0.016, 16);
expect(world.rungs()[2]!.flash).toBe(1);
world.update('toolkit', 0.5, 0, '1:0', 0.1, 116);
expect(world.rungs()[11]!.flash).toBe(1);
world.update('toolkit', 0.5, 0, '0:2', 0.1, 216);
expect(world.rungs()[2]!.flash).toBeLessThan(1);
world.update('toolkit', 0.5, 0, '1:0', RUNG_FLASH_GAP_SECONDS, 566);
world.update('toolkit', 0.5, 0, '0:2', 0.016, 582);
expect(world.rungs()[2]!.flash).toBe(1);
});
it('lite: every rung lit from chapter entry, steady beacons, no hover flash and no tone', () => {
const { world, tone } = station(false);
world.update('toolkit', 0.02, 0, undefined, 0.016, 0);
expect(world.rungs().every((rung) => rung.lit === 1)).toBe(true);
const levels = new Set<number>();
for (let t = 0; t < 4000; t += 100) {
world.update('toolkit', 0.3 + t / 20000, 0, t % 200 === 0 ? '0:1' : '0:2', 0.1, t);
world.beacons().forEach((level) => levels.add(Math.round(level * 1000)));
}
expect(levels.size).toBe(1);
expect(world.rungs().every((rung) => rung.flash === 0)).toBe(true);
expect(tone).not.toHaveBeenCalled();
});
it('full: beacons blink at 0.5 Hz (one cycle every 2 s)', () => {
const { world } = station(true);
const samples: number[] = [];
for (let t = 0; t <= 2000; t += 50) {
world.update('toolkit', 0.7, 0, undefined, 0.05, t);
samples.push(world.beacons()[0]!);
}
expect(samples[0]).toBeCloseTo(samples[40]!, 5);
expect(Math.max(...samples) - Math.min(...samples)).toBeGreaterThan(0.5);
});
it('nudges the lens while a mast is online and eases back', () => {
const { world } = station(true);
let nudge = 0;
for (let i = 0; i < 60; i += 1) nudge = world.update('toolkit', 0.5, 0, undefined, 0.016, i * 16);
expect(nudge).toBe(1);
for (let i = 0; i < 60; i += 1) nudge = world.update('contact', null, 0, undefined, 0.016, 1000 + i * 16);
expect(nudge).toBe(0);
});
});
describe('relay tone', () => {
it('stays silent without an audible switchboard', () => {
expect(readSwitchboard()).toBeNull();
expect(playRelayTone()).toBe(false);
});
});

View File

@@ -0,0 +1,119 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { useRelay } from '@/composables/useRelay';
import { BEACON_HZ, publishRelay, readRelay, RELAY_STOPS, relayOnline, rungLit } from '@/journey/relay-progress';
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 counts = content.toolkit.map((group) => group.technologies.length);
const sweep = (from: number, to: number, steps = 400): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
afterEach(() => publishRelay([]));
describe('relay progress', () => {
it('has one stop per group and blinks well under 3 Hz', () => {
expect(RELAY_STOPS).toHaveLength(4);
expect(counts).toHaveLength(4);
expect(BEACON_HZ).toBeLessThanOrEqual(3);
});
it('brings masts online monotonically and takes them offline in reverse', () => {
const forward = sweep(0, 1).map(relayOnline);
forward.forEach((n, i) => { if (i > 0) expect(n).toBeGreaterThanOrEqual(forward[i - 1]!); });
const backward = [...sweep(0, 1)].reverse().map(relayOnline);
expect(backward).toEqual([...forward].reverse());
expect(relayOnline(null)).toBe(0);
expect(relayOnline(0)).toBe(0);
expect(relayOnline(0.6)).toBe(4);
});
it('lights every rung monotonically and reversibly, all lit by the end of the ramp', () => {
counts.forEach((count, mast) => {
for (let rung = 0; rung < count; rung += 1) {
const forward = sweep(0, 1).map((p) => rungLit(p, mast, rung, count));
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
expect([...sweep(0, 1)].reverse().map((p) => rungLit(p, mast, rung, count))).toEqual([...forward].reverse());
expect(rungLit(RELAY_STOPS[mast]! - 0.001, mast, rung, count)).toBe(0);
expect(rungLit(0.71, mast, rung, count)).toBe(1);
}
});
expect(rungLit(null, 0, 0, 3)).toBe(0);
});
it('lights rungs bottom-up in index order', () => {
for (const p of sweep(0, 1)) {
counts.forEach((count, mast) => {
for (let rung = 1; rung < count; rung += 1) {
expect(rungLit(p, mast, rung, count)).toBeLessThanOrEqual(rungLit(p, mast, rung - 1, count));
}
});
}
});
it('carries the rung counts from the DOM to the scene', () => {
expect(readRelay().counts).toEqual([]);
publishRelay(counts);
expect(readRelay().counts).toEqual([11, 8, 4, 3]);
publishRelay([1, 2, 3, 4, 5]);
expect(readRelay().counts).toHaveLength(4);
});
});
describe('useRelay', () => {
const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0.8,
direction: 0,
acceleration: 0,
driveSpeed: 0,
activeSectionId,
chapter: 'systems',
sign: null,
arrived: false,
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n },
} as unknown as JourneySnapshot);
it('sets html[data-relay-online] through Toolkit only, publishes counts, and cleans up', async () => {
const journey = shallowRef(snapshot('additional-work', -0.5, 1.5));
const wrapper = mount(defineComponent({
setup() {
useRelay(journey, () => content.toolkit);
return () => h('span');
},
}));
const root = document.documentElement;
await nextTick();
expect(readRelay().counts).toEqual(counts);
expect(root.dataset.relayOnline).toBeUndefined();
journey.value = snapshot('toolkit', -0.1, 1.9); // p 0.05
await nextTick();
expect(root.dataset.relayOnline).toBe('1');
journey.value = snapshot('toolkit', -1, 1); // p 0.5
await nextTick();
expect(root.dataset.relayOnline).toBe('3');
journey.value = snapshot('toolkit', -1.3, 0.7); // p 0.65
await nextTick();
expect(root.dataset.relayOnline).toBe('4');
journey.value = snapshot('toolkit', -0.5, 1.5); // p 0.25, scrolling back
await nextTick();
expect(root.dataset.relayOnline).toBe('2');
journey.value = snapshot('contact', -0.2, null);
await nextTick();
expect(root.dataset.relayOnline).toBeUndefined();
journey.value = snapshot('toolkit', -1, 1);
await nextTick();
expect(root.dataset.relayOnline).toBe('3');
wrapper.unmount();
expect(root.dataset.relayOnline).toBeUndefined();
expect(root.dataset.relayFocus).toBeUndefined();
});
});