feat: relay station masts come online through the toolkit chapter
This commit is contained in:
158
tests/unit/relay-masts.test.ts
Normal file
158
tests/unit/relay-masts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user