Files
Flight-Ops/e2e/queue.spec.ts
2026-09-21 01:28:41 +00:00

389 lines
19 KiB
TypeScript
Raw Permalink 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.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a
// Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate
// while flying, ISS-053), and
// a flying plane with queued legs draws them as a DOTTED line (`queue-routes` layer) chaining
// dest → queue[0] → … — the active leg stays in the solid `routes-legs` layer. Landing auto-starts
// the next queued leg server-side (part 1); test 3 exercises that chain end-to-end through the API.
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // Madrid–Barajas — the typical long leg (~10 s at scale 1000)
const QUEUE = 'EHAM'; // Amsterdam — a third airport, distinct from hub and destination
interface PlaneState {
id: string;
status: 'available' | 'flying';
locationIcao: string;
destIcao: string | null;
queue: string[];
}
interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
}
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;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck. ISS-041 also clears pause: a
// landing pauses the sim, so any fast-forward after one must resume it or the clock stays frozen.
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 { timeScale: number };
expect(body.timeScale, `timeScale should be ${scale} after POST /api/sim`).toBe(scale);
}
// 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 hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Self-heal: let any in-flight leg finish, reposition plane-starter home via the API, and abandon
// leftover cargo so every test starts from the canonical state even after an interrupted 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') {
// 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 === 'plane-starter')?.status ?? ''), {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.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.
await setScale(page, SCALE);
const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // 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,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(HUB);
}
// Abandon leftover loaded/expired packages (the baseline is captured AFTER this cleanup).
const s1 = await getState(page);
for (const pkg of s1.packages) {
if (pkg.planeId === 'plane-starter' && (pkg.status === 'loaded' || pkg.status === 'expired')) {
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.ok()).toBeTruthy();
}
}
}
async function waitForAirportCount(page: Page): Promise<void> {
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}).toBeGreaterThan(0);
}
// Rendered features of the given layers in the current viewport, [] until the map exists.
function renderedFeatures(
page: Page,
layers: string[],
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap;
if (!m) return [];
try {
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props, so
// geometry silently vanishes across the page boundary. Map to plain objects IN-PAGE (ORCHESTRATOR §6).
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
type?: unknown;
id?: unknown;
properties?: unknown;
geometry?: unknown;
}>).map((f) => ({
type: f.type,
id: f.id,
properties: f.properties,
geometry: f.geometry,
}));
} catch {
return [];
}
}, layers);
}
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns features
// rendered inside the CURRENT viewport (same helper as e2e/hover.spec.ts).
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const points = icaos.map((icao) => {
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready');
const lons = pts.map((p) => p[0]);
const lats = pts.map((p) => p[1]);
// duration 0 → instant camera jump, no animation to wait out.
m.fitBounds(
[
[Math.min(...lons), Math.min(...lats)],
[Math.max(...lons), Math.max(...lats)],
],
{ padding: 40, duration: 0 },
);
}, points);
// Give the renderer a beat after the camera jump before asserting on rendered features.
await page.waitForTimeout(300);
}
// [lon, lat] of an airport from the same /api/airports dataset the map draws its lines from.
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat];
}
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses with coordinates quantized to
// the grid — check endpoints ACROSS all segments with an EPS (same pattern as expectHoverRoute in
// e2e/hover.spec.ts). ~5 km: above worst-case quantization at any zoom, below the distance between
// any two EU airports.
const QUEUE_ROUTE_EPS_DEG = 0.05;
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
test('queue display + cancel via UI (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// Full tank before departure — keeps the long EDDF→LEMD leg fuel-feasible (same as tests 2 & 3).
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
const pkg = await createPackage(page, HUB, DEST);
await page.goto('/');
await waitForAirportCount(page);
// LOAD via the UI (right-pane job list) — the starter is grounded at the package's origin.
const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn, 'Load button enabled for a package at the hub').toBeEnabled({ timeout: 15_000 });
await loadBtn.click();
const onboardRow = page.getByTestId(`onboard-package-${pkg.id}`);
await expect(onboardRow, 'loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// LeftPane's own /api/airports fetch populates the Relocate destination select — wait for it.
const destSelect = page.getByTestId('relocate-dest');
await expect(destSelect.locator(`option[value="${QUEUE}"]`)).toHaveCount(1, { timeout: 15_000 });
// START leg 1 via the UI (fly-to button in the on-board block).
const flyBtn = page.getByTestId(`fly-to-${pkg.id}`);
await expect(flyBtn).toBeEnabled();
await flyBtn.click();
// The click fires the fly POST asynchronously — poll until it took effect server-side, then FREEZE
// immediately: at scale 1000 (left over from self-heal) the ~10 s leg would otherwise land under us.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === DEST;
}, { timeout: 15_000, message: 'waiting for the fly click to take effect (status "flying" + dest set)' }).toBe(true);
await postSim(page, { paused: true }); // pause-sim pattern: deterministic window for UI clicks
// ISS-065: re-queuing the CURRENT leg's destination while flying must be rejected with 409.
const dupQueue = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: DEST } });
expect(dupQueue.status()).toBe(409);
// ADD a leg to the queue via the Relocate section (ISS-053): the starter is flying, so relocate-go
// posts to /queue and the leg starts when it lands.
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await destSelect.selectOption(QUEUE);
await page.getByTestId('relocate-go').click();
// …and the new leg appears as queue-leg-0 with its destination.
const legRow = page.getByTestId('queue-leg-plane-starter-0');
await expect(legRow, 'the queued leg appears in the Queue sub-section').toContainText(QUEUE, { timeout: 15_000 });
// CANCEL it via the UI and assert it's gone.
await page.getByTestId('queue-cancel-plane-starter-0').click();
await expect(page.getByTestId('queue-leg-plane-starter-0'), 'the cancelled leg is removed from the queue').toHaveCount(0, { timeout: 15_000 });
// Cleanup: resume + fast-forward to arrival at DEST (empty queue → plain landing), then fly home.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('dotted queue line renders the queued path on the map (ISS-043 part 2)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — keeps the state canonical for whatever the cleanup does.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Load + queue + fly via the API: a package EDDF→LEMD, one queued leg LEMD→EHAM (works while
// grounded — part 1), then start leg 1. The dotted line must show ONLY dest → queue[0].
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
const flyBody = (await flyRes.json()) as StateBody;
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
// Freeze mid-flight so the ~10 s leg can't land under us while we assert.
await postSim(page, { paused: true });
await page.goto('/');
await waitForAirportCount(page);
// All line endpoints must be in-viewport for queryRenderedFeatures to see them.
await fitToAirports(page, [DEST, QUEUE]);
const destLL = await airportLonLat(page, DEST);
const queueLL = await airportLonLat(page, QUEUE);
const hubLL = await airportLonLat(page, HUB);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= QUEUE_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= QUEUE_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['queue-routes'])) as Array<{
properties?: Record<string, unknown>;
geometry?: { type: string; coordinates: [number, number][] };
}>;
// Filter to real LineString segments — keeps this poll callback total (a throw inside
// expect.poll fails the test instantly instead of retrying). "None yet" → keep polling.
const segs = feats.filter(
(f): f is { properties?: Record<string, unknown>; geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The line may be clipped into several per-tile segments — check the endpoints across all of them:
// some segment STARTS within EPS of DEST (the active leg's destination, NOT the plane's origin)…
const startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLL));
// …and some ENDS within EPS of the queued airport.
const endsAtQueue = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], queueLL);
});
// The active leg (HUB→DEST) must NOT be in this layer — no segment may start at the hub.
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLL));
// Every rendered feature belongs to plane-starter (the only queued plane).
const ownedByStarter = feats.every((f) => f.properties?.planeId === 'plane-starter');
return startsAtDest && endsAtQueue && noneStartsAtHub && ownedByStarter;
}, { timeout: 15_000, message: `waiting for the dotted queue line ${DEST} → ${QUEUE}` }).toBe(true);
// Cleanup: cancel the queued leg (so landing at DEST is a plain grounding), resume + fast-forward
// to arrival, then fly home.
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
expect(cancelRes.status()).toBe(200);
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` }).toBe(true);
// ISS-041: the arrival above paused the sim — setScale clears pause for the return leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? ''), {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
}).toBe(HUB);
});
test('landing auto-starts the next queued leg (ISS-043 chain end-to-end)', async ({ page }) => {
test.setTimeout(300_000); // self-heal + two chained legs at scale 1000
await flyHomeViaApi(page);
// Full tank so fuel covers the chained return leg (same feasibility rule as /fly).
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// Package EDDF→LEMD + a queued return leg LEMD→EDDF, then start leg 1.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: HUB } });
expect(queueRes.status()).toBe(200);
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(flyRes.status()).toBe(200);
// Fast-forward (also clears any pause). On landing at DEST the server must AUTO-START the queued
// return leg — no manual dispatch. The chained state persists for the whole ~10 s return leg, so a
// poll can't miss it: status 'flying' with destIcao = the first (only) queued ICAO.
await setScale(page, SCALE);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'flying' && p.destIcao === HUB;
}, { timeout: 120_000, message: `waiting for the auto-chained leg ${DEST} → ${HUB} to start` }).toBe(true);
// Cargo delivery ran BEFORE chaining (part 1 ordering) — the package is already delivered.
const s = await getState(page);
expect(s.packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
// Let the chained leg finish: it lands back at the hub, leaving the canonical onboarding state.
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 120_000, message: `waiting for the chained leg to land back at ${HUB}` }).toBe(true);
});