feat: scroll-driven maintenance window set-piece in the infrastructure chapter
This commit is contained in:
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