389 lines
19 KiB
TypeScript
389 lines
19 KiB
TypeScript
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);
|
||
});
|