Files
Flight-Ops/e2e/sim.spec.ts
T

154 lines
6.1 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.7.0 sim engine against the live origin: tick-on-read clock advance at a
// raised time scale, pause freezing, contract expiry (via the FLIGHT_TEST_HOOKS
// hook 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 = 60;
const MAX_DELTA_PER_TICK = MAX_TICK_ELAPSED_MS * SCALE; // 120_000 game ms per tick at scale 60
// Must match MARKET_TARGET_OPEN_CONTRACTS in lib/game/seed.ts.
const MARKET_TARGET = 10;
interface SimState {
clockMs: number;
timeScale: number;
paused: boolean;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: SimState;
planes: Array<Record<string, unknown>>;
contracts: Array<{ id: string; status: string; deadlineSimTime: number }>;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
test('timeScale 60 advances the clock by roughly real-time × 60 between polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE });
expect(post.status()).toBe(200);
const s1 = (await post.json()) as SimState;
expect(s1.timeScale).toBe(SCALE);
expect(s1.paused).toBe(false);
let prev = s1.clockMs;
for (let i = 0; i < 3; i++) {
await sleep(1_000); // one real second ≈ 60 game seconds at scale 60 — well under the clamp
const body = await getState(page);
const now = body.simState.clockMs;
expect(now).toBeGreaterThan(prev); // strictly advances while actively polled
expect(now - prev).toBeGreaterThanOrEqual(30_000); // ≥ ~500 ms real × 60
expect(now - prev).toBeLessThanOrEqual(MAX_DELTA_PER_TICK + 60); // clamp bounds each tick
prev = now;
}
});
test('paused freezes the clock exactly across polls', async ({ page }) => {
const post = await postSim(page, { timeScale: SCALE, paused: true });
expect(post.status()).toBe(200);
const s1 = (await post.json()) as SimState;
expect(s1.paused).toBe(true);
for (let i = 0; i < 3; i++) {
await sleep(500);
const body = await getState(page);
expect(body.simState.clockMs).toBe(s1.clockMs); // frozen — no advance while paused
}
});
test('an open contract past its short deadline expires and the market stays topped up', async ({ page }) => {
test.setTimeout(60_000);
const before = await getState(page);
const victim = before.contracts.find((c) => c.status === 'open');
expect(victim).toBeDefined(); // steady state always has open contracts
// Test-only hook (FLIGHT_TEST_HOOKS=1 in docker-compose): move an existing open
// contract's deadline ~10 game-seconds out so expiry is observable at scale 60.
const hook = await page.request.post('/api/test/expire-contract', { data: { contractId: victim!.id } });
expect(hook.status()).toBe(200);
const moved = (await hook.json()) as { id: string; deadlineSimTime: number };
expect(moved.id).toBe(victim!.id);
await postSim(page, { timeScale: SCALE });
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.contracts.find((c) => c.id === moved.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the short-deadline contract to expire' })
.toBe(true);
const after = expiredBody!;
expect(after.contracts.find((c) => c.id === moved.id)?.status).toBe('expired');
// Expired contracts leave the available set…
expect(after.contracts.filter((c) => c.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.contracts.filter((c) => c.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET);
});
test('the hook can also create a fresh short-deadline contract, which then expires', async ({ page }) => {
test.setTimeout(60_000);
const created = await page.request.post('/api/test/expire-contract');
expect(created.status()).toBe(201);
const c = (await created.json()) as { id: string; status: string };
expect(c.status).toBe('open');
await postSim(page, { timeScale: SCALE });
let expiredBody: StateBody | undefined;
await expect
.poll(async () => {
const body = await getState(page);
if (body.contracts.find((x) => x.id === c.id)?.status === 'expired') expiredBody = body;
return !!expiredBody;
}, { timeout: 30_000, message: 'waiting for the hook-created contract to expire' })
.toBe(true);
const openCount = expiredBody!.contracts.filter((x) => x.status === 'open').length;
expect(openCount).toBeGreaterThanOrEqual(MARKET_TARGET); // top-up held after this expiry too
});
test('POST /api/sim validates and clamps its input', async ({ page }) => {
const clampHigh = await postSim(page, { timeScale: 500 });
expect(clampHigh.status()).toBe(200);
expect(((await clampHigh.json()) as SimState).timeScale).toBe(60);
const clampLow = await postSim(page, { timeScale: 0.1 });
expect(clampLow.status()).toBe(200);
expect(((await clampLow.json()) as SimState).timeScale).toBe(1);
for (const body of [{ timeScale: 'fast' }, { paused: 'yes' }, { bogus: 1 }]) {
const res = await postSim(page, body);
expect(res.status()).toBe(400);
}
const notJson = await page.request.post('/api/sim', { data: 'nope{' });
expect(notJson.status()).toBe(400);
// Empty body is a valid no-op (just ticks + returns current state).
const empty = await postSim(page, {});
expect(empty.status()).toBe(200);
});