Files
Flight-Ops/e2e/sim.spec.ts
T
2026-09-23 08:09:29 +00:00

185 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
// v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully
// grounded fleet freezes the clock, background-tick advance at a raised rate while something is
// active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and
// market top-up.
// Must match lib/sim/engine.ts — kept in sync manually (same pattern as MIN_ZOOM/MAX_ZOOM in map.spec.ts).
const MAX_TICK_ELAPSED_MS = 2_000;
const SCALE = 1000; // test rate for fast-forwarding legs
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 2_000_000 game ms per tick at scale 1000
// Must match MARKET_TARGET_OPEN_PACKAGES in lib/game/seed.ts.
const MARKET_TARGET = 10;
const STARTER = 'plane-starter';
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number }; // ISS-117: the snapshot carries only the game clock
planes: Array<{ id: string; status: string }>;
packages: Array<{ id: string; status: string; deadlineSimTime: number }>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
// instantly fail any expect.poll callback, so retry briefly instead.
for (let attempt = 1; ; attempt++) {
const res = await page.request.get('/api/state');
if (res.status() === 200) return (await res.json()) as StateBody;
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
await page.waitForTimeout(500);
}
}
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate and VERIFY from the response that it stuck.
async function setScale(page: Page, scale: number): Promise<void> {
const res = await page.request.post('/api/test/set-timescale', { data: { timeScale: scale } });
expect(res.status()).toBe(200);
const body = (await res.json()) as { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
async function setHealth(page: Page, planeId: string, fields: Record<string, unknown>): Promise<void> {
const res = await page.request.post('/api/test/set-health', { data: { planeId, ...fields } });
expect(res.status()).toBe(200);
}
// A fully grounded fleet freezes the world clock (#135) — put the starter in repair so the clock
// runs at FULL scale for tests that need fast-forwarding. The €1.5k repair cost is irrelevant to
// every spec's relative cash assertions; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 50 });
expect((await page.request.post(`/api/planes/${STARTER}/repair`)).status()).toBe(200);
}
// Shorten the repair gate to "now" — the next advancing tick flips the plane back to available.
async function deactivateWorld(page: Page): Promise<void> {
await setHealth(page, STARTER, { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) => {
const s = await getState(page);
expect(s.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const c0 = s.simState.clockMs;
await sleep(2_500); // ~2.5 s of real idleness — several background ticks plus the reads below
const after = await getState(page);
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
});
test('timeScale 1000 advances the clock by roughly real-time × 1000 between polls', async ({ page }) => {
await activateWorld(page);
try {
await setScale(page, SCALE);
let prev = (await getState(page)).simState.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 1000 game seconds at scale 1000 — well under the clamp
const now = (await getState(page)).simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while something is active
expect(now - prev).toBeGreaterThanOrEqual(500_000); // ≥ ~500 ms real × 1000
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
} finally {
await deactivateWorld(page);
}
});
test('an open package past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const before = await getState(page);
const victim = before.packages.find((p) => p.status === 'open');
expect(victim).toBeDefined(); // steady state always has open packages
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
// package's deadline ~10 game-seconds out so expiry is observable at scale 1000.
const hook = await page.request.post('/api/test/expire-package', { data: { packageId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
await setScale(page, SCALE);
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline package to expire' })
.toBe(true);
const after = expiredBody!;
expect(after.packages.find((p) => p.id === moved.id)?.status).toBe('expired');
// Expired packages leave the available set…
expect(after.packages.filter((p) => p.status === 'open').some((c) => c.id === moved.id)).toBe(false);
// …and spawn tops the market back up to the target — never below it.
const openCount = after.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
} finally {
await deactivateWorld(page);
}
});
test('the hook can also create a fresh short-deadline package, which then expires', async ({ page }) => {
test.setTimeout(60_000);
await activateWorld(page);
try {
const created = await page.request.post('/api/test/expire-package');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
await setScale(page, SCALE);
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.packages.find((p) => p.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created package to expire' })
.toBe(true);
const openCount = expiredBody!.packages.filter((p) => p.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
} finally {
await deactivateWorld(page);
}
});
test('POST /api/test/set-timescale validates its input', async ({ page }) => {
const ok = await page.request.post('/api/test/set-timescale', { data: { timeScale: 500 } });
expect(ok.status()).toBe(200);
expect(((await ok.json()) as { timeScale: number }).timeScale).toBe(500); // echoed back for assertions
for (const body of [{ timeScale: 'fast' }, { timeScale: 1e9 }, { bogus: 1 }]) {
const res = await page.request.post('/api/test/set-timescale', { data: body });
expect(res.status()).toBe(400);
}
const notJson = await page.request.post('/api/test/set-timescale', { data: 'nope{' });
expect(notJson.status()).toBe(400);
// Empty body is a 400 — the hook requires an explicit rate (unlike the old /api/sim no-op).
const empty = await page.request.post('/api/test/set-timescale', { data: {} });
expect(empty.status()).toBe(400);
});