feat: scroll-driven maintenance window set-piece in the infrastructure chapter
This commit is contained in:
@@ -150,3 +150,15 @@ describe('semantic portfolio document', () => {
|
||||
expect(document.body.textContent).toContain('Footy Tracker');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the 03:12 window on the server', () => {
|
||||
it('ships no maintenance step and an empty, hidden HUD sub-line', async () => {
|
||||
const html = await render('/', validInputs);
|
||||
expect(html).not.toContain('data-maintenance-step');
|
||||
expect(html).not.toContain('MAINTENANCE WINDOW');
|
||||
const document = await renderedDocument();
|
||||
const subline = document.querySelector('.journey-hud__subline');
|
||||
expect(subline).not.toBeNull();
|
||||
expect(subline?.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -430,4 +430,22 @@ describe("JourneyHud", () => {
|
||||
expect(wrapper.text()).not.toContain("Low");
|
||||
expect(wrapper.attributes("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("hangs the maintenance sub-line under the plate only when one is given", () => {
|
||||
const quiet = mount(JourneyHud, {
|
||||
props: { journey: journey({ activeSectionId: "experience" }), sectionLabel: "Experience" },
|
||||
});
|
||||
expect(quiet.get(".journey-hud__subline").text()).toBe("");
|
||||
quiet.unmount();
|
||||
|
||||
const window = mount(JourneyHud, {
|
||||
props: {
|
||||
journey: journey({ activeSectionId: "infrastructure" }),
|
||||
sectionLabel: "Infrastructure",
|
||||
subline: "MAINTENANCE WINDOW \u00b7 03:12",
|
||||
},
|
||||
});
|
||||
expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12");
|
||||
window.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
147
tests/unit/maintenance-progress.test.ts
Normal file
147
tests/unit/maintenance-progress.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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 type { JourneySnapshot } from '@/journey/types';
|
||||
import {
|
||||
createMaintenanceDirection,
|
||||
maintenanceClock,
|
||||
maintenanceProgress,
|
||||
maintenanceStep,
|
||||
maintenanceSubline,
|
||||
} from '@/journey/maintenance-progress';
|
||||
import type { SectionId } from '@/content/types';
|
||||
|
||||
const at = (
|
||||
activeSectionId: SectionId,
|
||||
activeHeadingOffsetVh: number,
|
||||
nextHeadingOffsetVh: number | null,
|
||||
) => ({
|
||||
activeSectionId,
|
||||
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh, nextHeadingOffsetVh },
|
||||
});
|
||||
|
||||
const live = () => '14:07';
|
||||
|
||||
describe('maintenance progress', () => {
|
||||
it('is 0 at the Infrastructure heading and 1 at the next heading', () => {
|
||||
expect(maintenanceProgress(at('infrastructure', 0, 2.5))).toBe(0);
|
||||
expect(maintenanceProgress(at('infrastructure', -1.25, 1.25))).toBeCloseTo(0.5);
|
||||
expect(maintenanceProgress(at('infrastructure', -2.5, 0))).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps to the window and is null outside the chapter or without geometry', () => {
|
||||
expect(maintenanceProgress(at('infrastructure', 0.2, 2.7))).toBe(0);
|
||||
expect(maintenanceProgress(at('infrastructure', -3, -0.5))).toBe(1);
|
||||
expect(maintenanceProgress(at('experience', -1, 1))).toBeNull();
|
||||
expect(maintenanceProgress(at('selected-builds', -1, 1))).toBeNull();
|
||||
expect(maintenanceProgress(at('infrastructure', 0, null))).toBeNull();
|
||||
expect(maintenanceProgress(at('infrastructure', 1, 1))).toBeNull();
|
||||
expect(maintenanceProgress(at('infrastructure', Number.NaN, 1))).toBeNull();
|
||||
});
|
||||
|
||||
it('maps progress onto open, backup, transfer, verify and done', () => {
|
||||
expect(maintenanceStep(0)).toBe('open');
|
||||
expect(maintenanceStep(0.099)).toBe('open');
|
||||
expect(maintenanceStep(0.1)).toBe('backup');
|
||||
expect(maintenanceStep(0.349)).toBe('backup');
|
||||
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(1)).toBe('done');
|
||||
});
|
||||
|
||||
it('holds the clock at 03:12 while the window opens, cuts over at 03:51, then goes live', () => {
|
||||
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(0.999, live)).toBe('03:51');
|
||||
expect(maintenanceClock(1, live)).toBe('14:07');
|
||||
let previous = -1;
|
||||
for (let p = 0; p < 1; p += 0.01) {
|
||||
const minutes = Number(maintenanceClock(p, live).slice(3));
|
||||
expect(minutes).toBeGreaterThanOrEqual(previous);
|
||||
previous = minutes;
|
||||
}
|
||||
});
|
||||
|
||||
it('writes the sub-line from the step, and a rollback while reversing', () => {
|
||||
expect(maintenanceSubline(0.02, false, live)).toBe('MAINTENANCE WINDOW · 03:12');
|
||||
expect(maintenanceSubline(0.92, false, live)).toBe('CUTOVER OK · 03:51');
|
||||
expect(maintenanceSubline(0.5, true, live)).toMatch(/^ROLLBACK CHECKPOINT · 03:\d\d$/u);
|
||||
expect(maintenanceSubline(0.97, false, live)).toBe('CUTOVER OK · 03:51');
|
||||
});
|
||||
|
||||
it('latches a rollback while progress falls and clears it when it rises', () => {
|
||||
const track = createMaintenanceDirection();
|
||||
expect(track(0.2)).toBe(false);
|
||||
expect(track(0.3)).toBe(false);
|
||||
expect(track(0.25)).toBe(true);
|
||||
expect(track(0.25)).toBe(true);
|
||||
expect(track(0.2505)).toBe(true);
|
||||
expect(track(0.3)).toBe(false);
|
||||
expect(track(0.2)).toBe(true);
|
||||
expect(track(null)).toBe(false);
|
||||
expect(track(0.9)).toBe(false);
|
||||
expect(track(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMaintenanceWindow', () => {
|
||||
const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({
|
||||
scrollY: 0,
|
||||
routeProgress: 0.4,
|
||||
direction: 0,
|
||||
acceleration: 0,
|
||||
driveSpeed: 0,
|
||||
activeSectionId,
|
||||
chapter: 'infrastructure',
|
||||
sign: null,
|
||||
arrived: false,
|
||||
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n },
|
||||
});
|
||||
|
||||
it('steps html[data-maintenance-step] and the sub-line from scroll, then cleans up', async () => {
|
||||
const journey = shallowRef(snapshot('experience', -1, 1));
|
||||
let subline = shallowRef('');
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() {
|
||||
subline = useMaintenanceWindow(journey) as typeof subline;
|
||||
return () => h('span', subline.value);
|
||||
},
|
||||
}));
|
||||
const root = document.documentElement;
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBeUndefined();
|
||||
expect(subline.value).toBe('');
|
||||
|
||||
journey.value = snapshot('infrastructure', 0, 2);
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBe('open');
|
||||
expect(subline.value).toBe('MAINTENANCE WINDOW \u00b7 03:12');
|
||||
|
||||
journey.value = snapshot('infrastructure', -1.84, 0.16);
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBe('done');
|
||||
expect(subline.value).toBe('CUTOVER OK \u00b7 03:51');
|
||||
|
||||
journey.value = snapshot('infrastructure', -1, 1);
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBe('transfer');
|
||||
expect(subline.value).toMatch(/^ROLLBACK CHECKPOINT/u);
|
||||
|
||||
journey.value = snapshot('selected-builds', 0, 2);
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBeUndefined();
|
||||
expect(subline.value).toBe('');
|
||||
|
||||
journey.value = snapshot('infrastructure', -0.4, 1.6);
|
||||
await nextTick();
|
||||
expect(root.dataset.maintenanceStep).toBe('backup');
|
||||
wrapper.unmount();
|
||||
expect(root.dataset.maintenanceStep).toBeUndefined();
|
||||
});
|
||||
});
|
||||
155
tests/unit/maintenance-window.test.ts
Normal file
155
tests/unit/maintenance-window.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SceneResourceBag } from '@/scene/resources';
|
||||
import {
|
||||
createLightning,
|
||||
createMaintenanceWindow,
|
||||
LIGHTNING,
|
||||
MAINTENANCE_RAIN_PEAK,
|
||||
maintenanceTimeline,
|
||||
type MaintenanceFrame,
|
||||
} from '@/scene/world/maintenance-window';
|
||||
|
||||
const sweep = (from: number, to: number, steps = 400): number[] =>
|
||||
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
||||
|
||||
describe('the 03:12 window timeline', () => {
|
||||
it('powers down, re-lights and moves packets monotonically with progress', () => {
|
||||
let previous: MaintenanceFrame | null = null;
|
||||
for (const p of sweep(0, 1)) {
|
||||
const frame = maintenanceTimeline(p);
|
||||
if (previous !== null) {
|
||||
expect(frame.dim).toBeGreaterThanOrEqual(previous.dim);
|
||||
expect(frame.relight).toBeGreaterThanOrEqual(previous.relight);
|
||||
expect(frame.packetPhase).toBeGreaterThanOrEqual(previous.packetPhase);
|
||||
expect(frame.offsite).toBeGreaterThanOrEqual(previous.offsite);
|
||||
}
|
||||
previous = frame;
|
||||
}
|
||||
expect(maintenanceTimeline(0)).toMatchObject({ dim: 0, relight: 0, packetPhase: 0, step: 'open' });
|
||||
expect(maintenanceTimeline(1)).toMatchObject({ dim: 1, relight: 1, packetPhase: 2, step: 'done' });
|
||||
});
|
||||
|
||||
it('replays exactly in reverse (a rollback is the same frames backwards)', () => {
|
||||
const ps = sweep(0, 1);
|
||||
const forward = ps.map(maintenanceTimeline);
|
||||
const backward = [...ps].reverse().map(maintenanceTimeline).reverse();
|
||||
expect(backward).toEqual(forward);
|
||||
});
|
||||
|
||||
it('keeps the windshield dry at the edges of the window and peaks at 0.7', () => {
|
||||
for (const p of [...sweep(0, 0.1, 20), ...sweep(0.9, 1, 20)]) {
|
||||
expect(maintenanceTimeline(p).rain).toBe(0);
|
||||
}
|
||||
const peak = Math.max(...sweep(0, 1).map((p) => maintenanceTimeline(p).rain));
|
||||
expect(peak).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 5);
|
||||
expect(maintenanceTimeline(0.5).rain).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 5);
|
||||
});
|
||||
|
||||
it('stops the lightning and eases the storm out by cutover', () => {
|
||||
expect(maintenanceTimeline(0.3).lightning).toBe(1);
|
||||
for (const p of sweep(0.7, 1, 30)) expect(maintenanceTimeline(p).lightning).toBe(0);
|
||||
expect(maintenanceTimeline(0.05).storm).toBeGreaterThan(0.5);
|
||||
expect(maintenanceTimeline(0.95).storm).toBe(0);
|
||||
expect(maintenanceTimeline(0).storm).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the packets out during the backup and back during verification', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('distant lightning', () => {
|
||||
const strikes = (seconds: number, enable = 1, dt = 1 / 60, seed?: number) => {
|
||||
const lightning = createLightning(seed);
|
||||
const onsets: number[] = [];
|
||||
let previous = 0;
|
||||
let peak = 0;
|
||||
for (let t = 0; t < seconds; t += dt) {
|
||||
const flash = lightning.sample(dt, enable);
|
||||
if (flash > previous && previous === 0) onsets.push(t);
|
||||
previous = flash;
|
||||
peak = Math.max(peak, flash);
|
||||
}
|
||||
return { onsets, peak };
|
||||
};
|
||||
|
||||
it('strikes at most 0.35 times a second, never twice within 2.8 s', () => {
|
||||
for (const seed of [1, 0x0312, 99, 4242]) {
|
||||
const { onsets } = strikes(300, 1, 1 / 60, seed);
|
||||
expect(onsets.length).toBeGreaterThan(20);
|
||||
expect(onsets.length / 300).toBeLessThanOrEqual(0.35);
|
||||
for (let i = 1; i < onsets.length; i += 1) {
|
||||
expect(onsets[i]! - onsets[i - 1]!).toBeGreaterThanOrEqual(2.8);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('caps the peak and stays dark while disabled', () => {
|
||||
expect(strikes(120).peak).toBeLessThanOrEqual(LIGHTNING.peak + 1e-9);
|
||||
expect(strikes(120, 0).onsets).toHaveLength(0);
|
||||
expect(strikes(120, 0).peak).toBe(0);
|
||||
});
|
||||
|
||||
it('settles before the first strike once the window opens', () => {
|
||||
const { onsets } = strikes(20);
|
||||
expect(onsets[0]).toBeGreaterThanOrEqual(LIGHTNING.settle - 1e-6);
|
||||
});
|
||||
|
||||
it('holds the rate at a low frame rate too', () => {
|
||||
const { onsets } = strikes(300, 1, 0.1);
|
||||
for (let i = 1; i < onsets.length; i += 1) {
|
||||
expect(onsets[i]! - onsets[i - 1]!).toBeGreaterThanOrEqual(2.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');
|
||||
expect(state.rain).toBe(0);
|
||||
expect(state.flash).toBe(0);
|
||||
expect(state.lightning).toBe(0);
|
||||
expect(state.dim).toBeGreaterThan(0.95);
|
||||
expect(state.relight).toBeGreaterThan(0.4);
|
||||
const packets = window.group.getObjectByName('maintenance-packets') as { visible: boolean };
|
||||
expect(packets.visible).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('eases out after the chapter and replays the same state when scrolled back', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const window = createMaintenanceWindow(resources);
|
||||
for (let i = 0; i < 100; i += 1) window.update(0.5, 1, 0.05, 'full');
|
||||
const forward = window.update(0.5, 1, 0.05, 'full');
|
||||
expect(forward.rain).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 2);
|
||||
for (let i = 0; i < 100; i += 1) window.update(0.8, 1, 0.05, 'full');
|
||||
for (let i = 0; i < 100; i += 1) window.update(0.5, -1, 0.05, 'full');
|
||||
const back = window.update(0.5, -1, 0.05, 'full');
|
||||
expect(back.reversing).toBe(true);
|
||||
expect(back.dim).toBeCloseTo(forward.dim, 3);
|
||||
expect(back.relight).toBeCloseTo(forward.relight, 3);
|
||||
expect(back.rain).toBeCloseTo(forward.rain, 3);
|
||||
for (let i = 0; i < 200; i += 1) window.update(null, 1, 0.05, 'full');
|
||||
const after = window.update(null, 1, 0.05, 'full');
|
||||
expect(after.presence).toBe(0);
|
||||
expect(after.dim).toBe(0);
|
||||
expect(after.rain).toBe(0);
|
||||
resources.dispose();
|
||||
});
|
||||
|
||||
it('reports a rollback while progress falls', () => {
|
||||
const resources = new SceneResourceBag();
|
||||
const window = createMaintenanceWindow(resources);
|
||||
window.update(0.6, 1, 0.05, 'full');
|
||||
expect(window.update(0.55, -1, 0.05, 'full').reversing).toBe(true);
|
||||
expect(window.update(0.58, 1, 0.05, 'full').reversing).toBe(false);
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user