import { test, expect, type Page } from '@playwright/test'; // v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map. // Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying); // open-package rows highlight both endpoint airports. The map exposes three top-most // highlight layers (planes-highlight / routes-highlight / airports-highlight), each filtered // to match nothing until a hover sets the filter — asserted here via window.__flightMap's // queryRenderedFeatures, same pattern as e2e/map.spec.ts and e2e/map-interactions.spec.ts. 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 // Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000. const DEST = 'LEMD'; interface PlaneState { id: string; model: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; destIcao: string | null; } interface PackageState { id: string; originName: string; destName: string; reward: number; status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; planeId: string | null; } interface StateBody { simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; packages: PackageState[]; } async function getState(page: Page): Promise { 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 — a silent reset would // otherwise surface as an unexplained landing-wait timeout. 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); } // 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 { 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, then reposition plane-starter home via the API. async function flyHomeViaApi(page: Page): Promise { const s0 = await getState(page); const starter = s0.planes.find((p) => p.id === 'plane-starter')!; if (starter.status === 'flying' || starter.locationIcao !== HUB) { // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). await setScale(page, SCALE); } if (starter.status === 'flying') { await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 240_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); } if (starter.locationIcao !== HUB) { const res = await page.request.post('/api/planes/plane-starter/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 === HUB, { timeout: 240_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); } } async function waitForAirportCount(page: Page): Promise { 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[]) { return page.evaluate((ls: string[]) => { const m = (window as any).__flightMap; if (!m) return []; try { return m.queryRenderedFeatures(undefined, { layers: ls }); } catch { return []; } }, layers); } // Move the pointer to a neutral element with no hover handler — leaves any hovered row/block. async function unhover(page: Page): Promise { await page.getByTestId('wallet-cash').hover(); } test('hovering a fleet row highlights the plane on the map', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); // deterministic: the starter sits at EDDF, inside the initial view await page.goto('/'); await waitForAirportCount(page); const row = page.locator('li[data-testid="plane-starter"]'); await expect(row).toBeVisible({ timeout: 15_000 }); // No highlight before hovering (the layers start filtered to match nothing). expect(await renderedFeatures(page, ['planes-highlight'])).toHaveLength(0); await row.hover(); await expect.poll(async () => { const feats = (await renderedFeatures(page, ['planes-highlight'])) as Array<{ properties: Record }>; return feats.length > 0 && feats[0].properties.id === 'plane-starter'; }, { timeout: 15_000, message: 'waiting for the planes-highlight layer to show plane-starter' }).toBe(true); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, { timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); }); test('hovering an open package highlights its endpoint airports', async ({ page }) => { test.setTimeout(300_000); // self-heal can take minutes after an interrupted run await flyHomeViaApi(page); const pkg = await createPackage(page, HUB, DEST); await page.goto('/'); await waitForAirportCount(page); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); await row.hover(); await expect.poll(async () => { const feats = (await renderedFeatures(page, ['airports-highlight'])) as Array<{ properties: Record }>; return JSON.stringify(feats.map((f) => f.properties.icao).sort()); }, { timeout: 15_000, message: 'waiting for the airports-highlight layer to show both endpoints' }) .toBe(JSON.stringify([HUB, DEST].sort())); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, { timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); }); test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => { test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup await flyHomeViaApi(page); // Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank. const refuel = await page.request.post('/api/planes/plane-starter/refuel'); expect(refuel.status()).toBe(200); await page.goto('/'); await waitForAirportCount(page); // Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up. 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 fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } }); expect(fly.status()).toBe(200); await setScale(page, SCALE); // The on-board block shows the plane in flight once a snapshot with status 'flying' lands. const block = page.getByTestId('onboard-plane-starter'); await expect(block).toContainText(/in flight/i, { timeout: 15_000 }); // Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as // e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused. await postSim(page, { paused: true }); await block.hover(); await expect.poll(async () => { const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record }>; return feats.length > 0 && feats[0].properties.planeId === 'plane-starter'; }, { timeout: 15_000, message: 'waiting for the routes-highlight layer to show the plane-starter leg' }).toBe(true); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['routes-highlight'])).length, { timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); // Resume and fast-forward to arrival, then fly home so the live instance stays in its // onboarding state (starter grounded at EDDF) for the other specs. await postSim(page, { timeScale: SCALE, paused: false }); 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); 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 === HUB, { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); });