222 lines
11 KiB
TypeScript
222 lines
11 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
// v0.65.0: the open-package count one pool airport keeps — imported (like expiry.spec.ts does) so it
|
||
// can never drift from lib/game/seed.ts. With a single grounded starter plane there is exactly one
|
||
// pool, at EDDF.
|
||
import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed';
|
||
|
||
import { authenticate } from './auth';
|
||
|
||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||
|
||
// 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
|
||
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('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
|
||
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
|
||
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
|
||
await expect
|
||
.poll(async () => {
|
||
const res = await page.request.get('/api/state');
|
||
if (res.status() !== 200) return 'down';
|
||
const body = (await res.json()) as StateBody;
|
||
return body.planes.every((p) => p.status === 'available') ? 'idle' : 'busy';
|
||
}, { timeout: 120_000, message: 'waiting for the fleet to be fully grounded' })
|
||
.toBe('idle');
|
||
|
||
const before = await getState(page);
|
||
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
|
||
const clockBefore = before.simState.clockMs;
|
||
|
||
// Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
|
||
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
|
||
// only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
|
||
// reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
|
||
// its removal, so the market can never empty; cap the passes to bound runtime either way (a
|
||
// zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
|
||
// board comes back on the very next tick.
|
||
for (let pass = 0; pass < 3; pass++) {
|
||
const s = await getState(page);
|
||
const open = s.packages.filter((p) => p.status === 'open');
|
||
if (open.length === 0) break; // drained — the pre-fix soft-lock state
|
||
for (const p of open) {
|
||
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
|
||
}
|
||
}
|
||
|
||
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
|
||
// pool right away — within ~15 s of real time, with headroom for dind slowness…
|
||
let topped: StateBody | undefined;
|
||
await expect
|
||
.poll(async () => {
|
||
const body = await getState(page);
|
||
if (body.packages.some((p) => p.status === 'open')) topped = body;
|
||
return !!topped;
|
||
}, { timeout: 15_000, message: 'waiting for the pool to refill while grounded' })
|
||
.toBe(true);
|
||
|
||
const after = topped!;
|
||
expect(after.packages.filter((p) => p.status === 'open').length).toBeGreaterThan(0); // starvation fixed…
|
||
expect(after.planes.every((p) => p.status === 'available'), 'no plane may have taken off during the drain').toBe(true); // …with no plane flying…
|
||
expect(after.simState.clockMs, 'the grounded clock must stay exactly frozen while the market refills').toBe(clockBefore); // …and the world still frozen
|
||
});
|
||
|
||
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 job past its wall-clock lifetime expires and its pool slot refills', async ({ page }) => {
|
||
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): ages the job's createdRealMs past its
|
||
// lifetime and runs a tick, so it comes back already expired — v0.65.0 expiry is wall-clock, no
|
||
// scale or clock activation needed.
|
||
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; status: string };
|
||
expect(moved.id).toBe(victim!.id);
|
||
expect(moved.status, 'the aged job should be expired by the tick the hook runs').toBe('expired');
|
||
|
||
// Expired jobs leave the market…
|
||
let after: StateBody | undefined;
|
||
await expect
|
||
.poll(async () => {
|
||
const body = await getState(page);
|
||
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||
return !!after;
|
||
}, { timeout: 15_000, message: 'waiting for the pool to refill after expiry' })
|
||
.toBe(true);
|
||
// …and the spawner refills the pool back up — never below its target.
|
||
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET);
|
||
expect(after!.packages.some((p) => p.id === moved.id && p.status === 'open')).toBe(false); // expired stays out of the market
|
||
});
|
||
|
||
test('the hook can also create an already-aged job, which is expired by response time', async ({ page }) => {
|
||
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 };
|
||
// The hook ages the job before its final tick, so it never even appears open in /api/state.
|
||
expect(c.status).toBe('expired');
|
||
|
||
let after: StateBody | undefined;
|
||
await expect
|
||
.poll(async () => {
|
||
const body = await getState(page);
|
||
if (body.packages.filter((p) => p.status === 'open').length >= POOL_TARGET) after = body;
|
||
return !!after;
|
||
}, { timeout: 15_000, message: 'waiting for the pool to refill after the hook-created job expired' })
|
||
.toBe(true);
|
||
expect(after!.packages.filter((p) => p.status === 'open').length).toBeGreaterThanOrEqual(POOL_TARGET); // top-up held after this expiry too
|
||
});
|
||
|
||
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);
|
||
});
|