Merge branch 'polish/card-gaps' into redesign/overnight

This commit is contained in:
2026-10-12 19:22:21 +03:00
15 changed files with 193 additions and 28 deletions

View File

@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps';
import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress';
import { RELAY_STOPS, stationLive } from '@/journey/relay-progress';
import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress';
import { maintenanceTimeline } from '@/scene/world/maintenance-window';
import { relayPresence } from '@/scene/world/relay-masts';
const sweep = (from: number, to: number, steps = 200): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40);
describe('card gaps', () => {
it('matches the open road the stylesheet leaves between chapters', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8');
const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css);
expect(rule).not.toBeNull();
expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH);
expect(CARD_TITLE_VH).toBeGreaterThan(0);
expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom);
});
it('keeps the static window inside the measured gap for every chapter span', () => {
for (const span of spans) {
const window = gapWindow(span);
expect(window.from).toBeLessThanOrEqual(CARD_GAP.from);
expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to);
// The static peak sits where the gap is fully in frame, whatever the span.
expect(CARD_GAP.peak).toBeGreaterThan(window.from);
expect(CARD_GAP.peak).toBeLessThan(window.to);
}
expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak);
expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to);
expect(CARD_GAP.to).toBeLessThan(1);
});
it('relights the district and cuts over in the gap', () => {
expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1);
expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2);
expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from);
expect(maintenanceStep(CARD_GAP.peak)).toBe('done');
// The peak of the relight (its steepest frame) lies inside the window.
const ps = sweep(0, 1, 1000);
const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight));
const steepest = ps[slopes.indexOf(Math.max(...slopes))]!;
expect(steepest).toBeGreaterThan(CARD_GAP.from);
expect(steepest).toBeLessThan(CARD_GAP.to);
});
it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => {
const last = YARD_STOPS[YARD_STOPS.length - 1]!;
expect(last).toBe(CARD_GAP.peak);
expect(spreaderDrop(last)).toBe(1);
expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1);
expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to);
// The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame.
expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6);
expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0);
for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
});
it('brings the relay station live in the gap, after every mast is online and before they fade', () => {
expect(stationLive(null)).toBe(0);
expect(stationLive(CARD_GAP.from)).toBe(0);
expect(stationLive(CARD_GAP.peak)).toBe(1);
expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from);
for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1);
const forward = sweep(0, 1).map(stationLive);
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
});
});

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import { CARD_GAP } from '@/journey/card-gaps';
import type { JourneySnapshot } from '@/journey/types';
import {
createMaintenanceDirection,
@@ -48,8 +49,8 @@ describe('maintenance progress', () => {
expect(maintenanceStep(0.35)).toBe('transfer');
expect(maintenanceStep(0.649)).toBe('transfer');
expect(maintenanceStep(0.65)).toBe('verify');
expect(maintenanceStep(0.899)).toBe('verify');
expect(maintenanceStep(0.9)).toBe('done');
expect(maintenanceStep(CARD_GAP.from - 0.001)).toBe('verify');
expect(maintenanceStep(CARD_GAP.from)).toBe('done');
expect(maintenanceStep(1)).toBe('done');
});
@@ -57,7 +58,7 @@ describe('maintenance progress', () => {
expect(maintenanceClock(0, live)).toBe('03:12');
expect(maintenanceClock(0.1, live)).toBe('03:12');
expect(maintenanceClock(0.5, live)).toBe('03:32');
expect(maintenanceClock(0.9, live)).toBe('03:51');
expect(maintenanceClock(CARD_GAP.from, live)).toBe('03:51');
expect(maintenanceClock(0.999, live)).toBe('03:51');
expect(maintenanceClock(1, live)).toBe('14:07');
let previous = -1;

View File

@@ -57,8 +57,8 @@ describe('the 03:12 window timeline', () => {
expect(maintenanceTimeline(0.1).packetPhase).toBe(0);
expect(maintenanceTimeline(0.36).packetPhase).toBe(1);
expect(maintenanceTimeline(0.6).packetPhase).toBe(1);
expect(maintenanceTimeline(0.66).packetPhase).toBe(1);
expect(maintenanceTimeline(0.89).packetPhase).toBe(2);
expect(maintenanceTimeline(0.65).packetPhase).toBe(1);
expect(maintenanceTimeline(0.9).packetPhase).toBe(2);
});
});
@@ -111,8 +111,8 @@ describe('maintenance window meshes', () => {
it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => {
const resources = new SceneResourceBag();
const window = createMaintenanceWindow(resources);
let state = window.update(0.5, 1, 0.016, 'lite');
for (let i = 0; i < 200; i += 1) state = window.update(0.5, 1, 0.05, 'lite');
let state = window.update(0.95, 1, 0.016, 'lite');
for (let i = 0; i < 200; i += 1) state = window.update(0.95, 1, 0.05, 'lite');
expect(state.rain).toBe(0);
expect(state.flash).toBe(0);
expect(state.lightning).toBe(0);

View File

@@ -17,6 +17,7 @@ import {
YARD_STOPS,
yardStopsPassed,
} from '@/journey/yard-progress';
import { CARD_GAP } from '@/journey/card-gaps';
import { DECK_PASS_Z } from '@/journey/underpass';
import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass';
@@ -24,7 +25,7 @@ const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i
describe('night freight progress', () => {
it('has four ordered stops and stacks far to near', () => {
expect(YARD_STOPS).toEqual([0.1, 0.33, 0.56, 0.79]);
expect(YARD_STOPS).toEqual([0.12, 0.38, 0.64, CARD_GAP.peak]);
for (let i = 1; i < YARD_STOPS.length; i += 1) {
expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!);
@@ -39,7 +40,9 @@ describe('night freight progress', () => {
previous = z;
}
expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z);
expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z);
// The last lift is the chapter's final beat (CARD_GAP): the crane stays parked over the near stack to the boundary.
expect(craneZ(1)).toBeCloseTo(STACK_ZS[STACK_ZS.length - 1]!);
expect(CRANE_TO_Z).toBeGreaterThan(STACK_ZS[STACK_ZS.length - 1]!);
YARD_STOPS.forEach((stop, i) => {
for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!);
});
@@ -63,10 +66,10 @@ describe('night freight progress', () => {
expect(plateLit(stop - 0.05, i)).toBe(0);
});
expect(spreaderDrop(0.215)).toBe(0);
expect(spreaderDrop(0.95)).toBe(0);
expect(spreaderDrop(0.51)).toBe(0);
expect(plateLit(null, 0)).toBe(0);
expect(plateLit(0.5, 9)).toBe(0);
expect([0, 0.1, 0.2, 0.33, 0.6, 0.8, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
expect([0, 0.12, 0.2, 0.38, 0.7, 0.95, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
});
it('is symmetric in reverse: the same progress gives the same frame either way', () => {