CI / build + unit + e2e (pull_request) Successful in 7m43s
Replace the ISS-011 auto-slow with pause-on-landing: when the last plane
in flight lands, tickOnce sets sim_state.paused=true WITHOUT touching
timeScale (speed preserved). flyPlane() now clears paused before
persisting so starting a flight resumes a paused sim — inside runAction(),
tick + unpause + dispatch are atomic under the sim lock.
Tests: engine.test.ts auto-slow block rewritten for pause semantics; new
flyPlane test asserts dispatch clears paused with speed untouched. E2E:
auto-slow.spec.ts renamed to pause-on-landing.spec.ts asserting the new
behavior (paused=true AND timeScale still 1000 after landing; /fly clears
paused). Cross-cutting fix: every per-spec setScale helper now POSTs
{timeScale, paused:false} — a scale-only fast-forward after a landing would
otherwise leave the clock frozen. Deliberate mid-test pauses untouched.
139 lines
7.0 KiB
TypeScript
139 lines
7.0 KiB
TypeScript
import type { Page } from '@playwright/test';
|
||
import { expect, test } from '@playwright/test';
|
||
|
||
// v0.24.0 — ISS-041: when the LAST plane lands (no plane remains in flight) the sim pauses
|
||
// server-side WITHOUT touching timeScale (speed is preserved), and starting a flight
|
||
// (POST /api/planes/{id}/fly) auto-resumes a paused sim. The cycle: dispatch unpauses →
|
||
// last landing pauses → next dispatch unpauses again. Arrival-triggered by design: a grounded
|
||
// fleet at high scale is left alone, there is no idle-enforcement or auto-restore of pause.
|
||
|
||
const SCALE = 1000; // fastest sim speed — top of the v0.14.0 ladder (same convention as e2e/sim.spec.ts)
|
||
const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts)
|
||
|
||
interface SimState {
|
||
clockMs: number;
|
||
timeScale: number;
|
||
paused: boolean;
|
||
}
|
||
|
||
interface StateBody {
|
||
simState: SimState;
|
||
planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>;
|
||
}
|
||
|
||
async function getState(page: Page): Promise<StateBody> {
|
||
const res = await page.request.get('/api/state');
|
||
expect(res.ok()).toBeTruthy();
|
||
return (await res.json()) as StateBody;
|
||
}
|
||
|
||
async function postSim(page: Page, body: Record<string, unknown>): Promise<void> {
|
||
const res = await page.request.post('/api/sim', { data: body });
|
||
expect(res.status()).toBe(200);
|
||
}
|
||
|
||
// Set the time scale and VERIFY from the response that it stuck — if something external ever
|
||
// resets the live instance's controls mid-test, fail loudly here instead of timing out later.
|
||
// ISS-041 also clears pause: a landing pauses the sim, so any fast-forward after one must
|
||
// resume it or the clock stays frozen and the next wait hangs.
|
||
async function setScale(page: Page, scale: number): Promise<void> {
|
||
const res = await page.request.post('/api/sim', { data: { timeScale: scale, paused: false } });
|
||
expect(res.status()).toBe(200);
|
||
const body = (await res.json()) as SimState;
|
||
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
|
||
}
|
||
|
||
// Closest airport to the hub from /api/airports → shortest possible leg (fastest e2e flight).
|
||
async function nearestToHub(page: Page): Promise<{ icao: string }> {
|
||
const res = await page.request.get('/api/airports');
|
||
expect(res.ok()).toBeTruthy();
|
||
const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> };
|
||
const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties;
|
||
let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties;
|
||
for (const f of fc.features) {
|
||
if (f.properties.icao === HUB) continue;
|
||
const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon);
|
||
const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon);
|
||
if (d < dn) nearest = f.properties;
|
||
}
|
||
return { icao: nearest.icao };
|
||
}
|
||
|
||
// Self-heal helper (same pattern as e2e/relocation.spec.ts): let any in-flight leg finish and
|
||
// reposition plane-starter home via the API so every test starts from the canonical state even
|
||
// after an interrupted previous run.
|
||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||
let s = await getState(page);
|
||
let plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||
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') {
|
||
// Let the in-flight leg finish before repositioning. While flying, locationIcao stays at the
|
||
// DEPARTURE airport — re-read state after landing before deciding whether a repositioning
|
||
// flight is needed.
|
||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available');
|
||
s = await getState(page);
|
||
plane = s.planes.find((p) => p.id === 'plane-starter')!;
|
||
}
|
||
if (plane.locationIcao !== HUB) {
|
||
// ISS-041: the landing above paused the sim — setScale clears pause so the return leg
|
||
// fast-forwards instead of taking real-time hours.
|
||
await setScale(page, SCALE);
|
||
const res = await page.request.post(`/api/planes/${plane.id}/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 === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB);
|
||
}
|
||
}
|
||
|
||
// Self-heal in CLEANUP (afterEach): the test deliberately leaves plane-starter at the far end, so
|
||
// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs.
|
||
test.afterEach(async ({ page }) => {
|
||
await flyHomeViaApi(page);
|
||
await postSim(page, { timeScale: 1, paused: false });
|
||
});
|
||
|
||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||
|
||
test('last landing pauses the sim without changing speed; dispatch auto-resumes (ISS-041)', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + two short legs
|
||
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
|
||
|
||
const nearest = await nearestToHub(page);
|
||
|
||
// Set 1000× via the API and verify it stuck.
|
||
await setScale(page, SCALE);
|
||
|
||
// Dispatch plane-starter on the shortest leg from its hub (repositioning — no package needed).
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||
expect(flyRes.status()).toBe(200);
|
||
|
||
// Wait for the landing…
|
||
let landedBody: StateBody | undefined;
|
||
await expect
|
||
.poll(async () => {
|
||
const b = await getState(page);
|
||
const p = b.planes.find((x) => x.id === 'plane-starter')!;
|
||
if (p.status === 'available' && p.locationIcao === nearest.icao) landedBody = b;
|
||
return !!landedBody;
|
||
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${nearest.icao}` })
|
||
.toBe(true);
|
||
|
||
// …and the server paused the sim — persisted on sim_state, so this very snapshot shows it…
|
||
expect(landedBody!.simState.paused).toBe(true);
|
||
// …with timeScale UNCHANGED (no auto-slow anymore): speed is preserved for the next dispatch.
|
||
expect(landedBody!.simState.timeScale).toBe(SCALE);
|
||
|
||
// A subsequent /fly dispatch auto-resumes a paused sim — asserted on the endpoint's OWN full
|
||
// post-action snapshot, so no poll race with the client's next tick.
|
||
const fly2 = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
|
||
expect(fly2.status()).toBe(200);
|
||
const resumedBody = (await fly2.json()) as StateBody;
|
||
expect(resumedBody.simState.paused).toBe(false); // ISS-041 auto-unpause…
|
||
expect(resumedBody.simState.timeScale).toBe(SCALE); // …with the preserved speed still in effect
|
||
|
||
// Let the return leg finish so cleanup starts from a grounded plane.
|
||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 120_000 }).toBe('available');
|
||
});
|