Fix e2e specs for fixed world clock: idle-pause expiry window, scale-1 motion settle, self-heal (ISS-117)
CI / build + unit + e2e (pull_request) Successful in 12m44s

This commit is contained in:
2026-09-22 14:00:53 +00:00
parent a0560ec767
commit 189a2c04eb
4 changed files with 86 additions and 10 deletions
+36 -1
View File
@@ -87,6 +87,31 @@ async function setScale(page: Page, scale: number): Promise<void> {
expect(body.timeScale, `timeScale should be ${scale} after POST /api/test/set-timescale`).toBe(scale);
}
// Test hook (FLIGHT_TEST_HOOKS=1): force a plane's health and/or repair gate — same shape as e2e/sim.spec.ts.
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);
}
// Idle-pause (ISS-117) freezes a fully grounded fleet — put the starter in repair so the clock
// advances for tests that need it. The €1.5k repair cost is why the expiry test captures its
// cash/rep baseline AFTER deactivateWorld; deactivateWorld restores full health and no gate afterwards.
async function activateWorld(page: Page): Promise<void> {
await setHealth(page, 'plane-starter', { health: 50 });
expect((await page.request.post('/api/planes/plane-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, 'plane-starter', { health: 100, repairUntilMs: 0 });
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', {
timeout: 30_000,
message: 'waiting for the starter to flip back to available',
})
.toBe('available');
}
// Closest airport to the hub from /api/airports → shortest possible flight. Returns both
// endpoints' coordinates so exact-km assertions (rep/fuel) can mirror lib/geo.ts.
async function nearestToHub(page: Page): Promise<{ icao: string; lat: number; lon: number; hubLat: number; hubLon: number }> {
@@ -296,7 +321,6 @@ test('abandon while grounded deducts exactly the formula penalty', async ({ page
test('expired packages deliver and abandon with no reward and no penalty', async ({ page }) => {
test.setTimeout(480_000); // two ~10 s real-time legs at 1000× (HUB↔LEMD) plus expiry wait
await flyHomeViaApi(page);
const before = await getState(page);
// A: load, let it expire while loaded, then FLY to its destination — arrival must
// unbind it WITHOUT paying out. B: load, let it expire, abandon for free. B's
@@ -310,12 +334,23 @@ test('expired packages deliver and abandon with no reward and no penalty', async
expect(res.ok()).toBeTruthy();
}
// Idle-pause (ISS-117): a fully grounded fleet freezes the clock, so expirePackages never runs —
// put the starter in repair for the expiry window only. The €1.5k repair cost is why the cash/rep
// baseline below is captured AFTER deactivateWorld, not before it.
await activateWorld(page);
try {
// Fast-forward past both deadlines — the tick flips loaded → expired (slot kept).
await setScale(page, SCALE);
let s = await getState(page);
await expect.poll(async () => ((await getState(page)).packages.find((p) => p.id === pkgA.id)!.status), { timeout: 30_000 }).toBe('expired');
s = await getState(page);
expect(s.packages.find((p) => p.id === pkgB.id)!.status).toBe('expired');
} finally {
await deactivateWorld(page);
}
const before = await getState(page); // baseline AFTER the repair cost — no-reward assertions compare against this
let s = before;
// Fly to the destination — arrival unbinds A for free (no reward, no rep).
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: 'LEMD' } });
+8 -2
View File
@@ -258,15 +258,21 @@ test('plane markers move while flying and the plane popup shows status + route',
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
// we restore SCALE right after the popup checks.
await setScale(page, 1);
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
// would show (~km per poll pair).
let prevPos: { lat: number; lon: number } | null = null;
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false;
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
if (prevPos && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
prevPos = { lat: p.lat, lon: p.lon };
return false;
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to settle the plane position' })
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
.toBe(true);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
+35 -1
View File
@@ -4,6 +4,8 @@ import { test, expect, type Page } from '@playwright/test';
// The panes render from the shell's single /api/state poll (lib/useGameState.ts).
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
const SCALE = 1000; // test rate for fast-forwarding legs (self-heal only)
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const STARTER = 'plane-starter';
interface StateBody {
@@ -51,6 +53,34 @@ async function deactivateWorld(page: Page): Promise<void> {
.toBe('available');
}
// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and
// reposition plane-starter home via the API — an interrupted earlier run can leave it mid-flight,
// and repairPlane 409s on non-available planes, which would break activateWorld.
async function flyHomeViaApi(page: Page): Promise<void> {
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
// active repair gate from an interrupted run before acting on the starter.
expect((await page.request.post('/api/test/set-health', { data: { planeId: STARTER, health: 100, repairUntilMs: 0 } })).status()).toBe(200);
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
let plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
if (plane.status === 'flying' || plane.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (plane.status === 'flying') {
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.status ?? ''), { timeout: 240_000 }).toBe('available');
s = await getState(page);
plane = (s.planes.find((p) => p.id === STARTER) ?? {}) as { status?: string; locationIcao?: string };
}
if (plane.locationIcao !== HUB) {
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight)
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === STARTER)?.locationIcao ?? ''), { timeout: 240_000 }).toBe(HUB);
}
}
// Leave the live instance in a sane state for the other specs (the fixed world rate) — same
// pattern as e2e/sim.spec.ts.
test.afterEach(async ({ page }) => {
@@ -111,7 +141,11 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt',
});
test('sim clock ticks smoothly between polls at the fixed world rate (ISS-007, ISS-117)', async ({ page }) => {
test.setTimeout(60_000);
test.setTimeout(300_000); // self-heal (worst case ~260 s at 1000×) + clock sampling
// Self-heal first: an interrupted earlier run can leave the starter mid-flight, and repairPlane
// 409s on non-available planes — it must be grounded before activateWorld.
await flyHomeViaApi(page);
await setScale(page, WORLD); // self-heal may have left the fast test rate behind
// Idle-pause freezes a grounded fleet — put the starter in repair so the clock advances.
await activateWorld(page);
try {
+1
View File
@@ -165,6 +165,7 @@ async function open(file: string): Promise<GameDb> {
// First-run seed: only when the DB is empty (no account row yet).
const res = raw.exec('SELECT COUNT(*) FROM account');
if (Number(res[0]?.values?.[0]?.[0] ?? 0) === 0) {
console.warn(`flight db: no existing world at ${file} — seeding a fresh one`);
await seedEmpty(instance);
}