import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. // Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // been allowed since v0.12.0 — no API change): the right-pane market row // (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on // the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by // the existing e2e/map.spec.ts click tests, not here. const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px // away at the initial zoom, so a click on its projected center cannot hit another circle. const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; interface StateBody { planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>; } async function getState(page: Page): Promise { 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): Promise { const res = await page.request.post('/api/sim', { data: body }); expect(res.ok()).toBeTruthy(); } // 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. async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/sim', { 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/sim`).toBe(scale); } // Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial). 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/actions.spec.ts): let any in-flight leg finish and // reposition plane-starter home via the API — repositioning needs no package (v0.12.0 manual // flight) — so every test starts from the canonical state even after an interrupted previous run. async function flyHomeViaApi(page: Page): Promise { 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 // 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) { 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 tests deliberately leave plane-starter mid-flight, 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('market row relocates a grounded plane via the right pane', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg await flyHomeViaApi(page); // Full tank before departure — a partially drained plane left by an earlier spec could not // afford the cleanup round trip. const refuel = await page.request.post('/api/planes/plane-starter/refuel'); expect(refuel.ok()).toBeTruthy(); const nearest = await nearestToHub(page); await page.goto('/'); const planeSel = page.getByTestId('relocate-plane'); await expect(planeSel).toBeVisible({ timeout: 15_000 }); // pane renders once the first snapshot lands await planeSel.selectOption('plane-starter'); // Destination options come from the one-time /api/airports fetch (sorted by name) — selectOption // waits until the matching option exists, so this doubles as the load wait. const destSel = page.getByTestId('relocate-dest'); await destSel.selectOption(nearest.icao); const goBtn = page.getByTestId('relocate-go'); await expect(goBtn).toBeEnabled(); await goBtn.click(); // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), // so poll until it took effect on the server. await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === nearest.icao; }, { timeout: 15_000, message: 'waiting for the relocate click to take effect (status "flying" + dest set)' }).toBe(true); }); test('airport popup offers Relocate options for grounded planes', async ({ page }) => { test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS await flyHomeViaApi(page); const refuel = await page.request.post('/api/planes/plane-starter/refuel'); expect(refuel.ok()).toBeTruthy(); await page.goto('/'); // Wait until the airport count renders (airports loaded) and the map has a snapshot containing // the plane — the popup's Relocate row is built from that same latest snapshot. 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; }, { timeout: 15_000 }).toBeGreaterThan(0); await expect.poll(async () => page.evaluate(() => { const m = (window as any).__flightMap; if (!m || !m.getSource('planes')) return false; try { return m.querySourceFeatures('planes').some((f: { properties?: Record }) => f.properties?.id === 'plane-starter'); } catch { return false; // source not ready yet — keep polling } }), { timeout: 15_000 }, ).toBe(true); // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). const pt = await page.evaluate(() => { const map = (window as any).__flightMap; const p = map.project([POPUP_AIRPORT.lon, POPUP_AIRPORT.lat]); const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; }); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); await expect(popup).toBeVisible(); await expect(popup).toContainText(POPUP_AIRPORT.icao); // The Relocate row: a select of grounded planes + the button (disabled until one is picked). const planeSel = page.getByTestId('airport-popup-plane'); await expect(planeSel).toBeVisible(); await expect(planeSel.locator('option')).toContainText(/ATR 72-600 — EDDF/); const goBtn = page.getByTestId('airport-popup-relocate'); await expect(goBtn).toBeDisabled(); // placeholder selected await planeSel.selectOption('plane-starter'); await expect(goBtn).toBeEnabled(); await goBtn.click(); await expect.poll(async () => { const b = await getState(page); const p = b.planes.find((x) => x.id === 'plane-starter')!; return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao; }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true); });