import { test, expect, type Page } from '@playwright/test'; // v0.11.0 — map interactions against the live origin (PLAN.md "Middle — Map"): // expanded airport popup (name + ICAO kept, plus approximate local time and a // deterministic generated weather), plane markers whose positions come from the // /api/state polls, active contract route lines (pending dashed vs in-flight // solid), and the plane status popup. Contracts are created on a known EDDF→EDDV // route via POST /api/test/create-contract (FLIGHT_TEST_HOOKS=1). const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts // Hannover — ~282 km from EDDF → ~33 min game time ≈ 33 s at scale 60, a comfortable // window for the click assertions below (the nearest airport would land too fast). const DEST = 'EDDV'; const SCALE = 60; interface PlaneState { id: string; model: string; status: 'available' | 'flying'; fuel: number; maxFuel: number; locationIcao: string; contractId: string | null; lat: number; lon: number; } interface ContractState { id: string; originIcao: string; originName: string; destIcao: string; destName: string; reward: number; status: 'open' | 'accepted' | 'completed' | 'expired'; planeId: string | null; } interface StateBody { account: { cash: number; reputation: number }; simState: { clockMs: number; timeScale: number; paused: boolean }; planes: PlaneState[]; contracts: ContractState[]; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // Mirrors haversineKm in lib/geo.ts (same formula → identical floats). function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a))); } 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 }); } // 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 contract on a known route. async function createContract(page: Page, fromIcao: string, toIcao: string): Promise { const created = await page.request.post('/api/test/create-contract', { data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 }, }); expect(created.status()).toBe(201); return (await created.json()) as ContractState; } // Create + accept + dispatch a contract from → to on plane-starter, then poll at // scale 60 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts. async function flyContract(page: Page, fromIcao: string, toIcao: string): Promise { const contract = await createContract(page, fromIcao, toIcao); const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); expect(acc.status()).toBe(200); const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(disp.status()).toBe(200); await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const body = await getState(page); const p = body.planes.find((x) => x.id === 'plane-starter'); return !!p && p.status === 'available' && p.locationIcao === toIcao; }, { timeout: 120_000, message: `waiting for plane-starter to land at ${toIcao}` }) .toBe(true); return contract; } // All features of a GeoJSON source (viewport-independent), [] until the map/source exists. function sourceFeatures(page: Page, sourceId: string) { return page.evaluate((srcId: string) => { const m = (window as any).__flightMap; if (!m || !m.getSource(srcId)) return []; try { return m.querySourceFeatures(srcId); } catch { return []; } }, sourceId); } 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); } test('airport popup shows name, ICAO, local time and generated weather', async ({ page }) => { await page.goto('/'); await waitForAirportCount(page); // Wait until the map instance exists and airport circles are actually rendered. await expect.poll(async () => page.evaluate( () => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'] })?.length ?? 0, ), { timeout: 15_000 }, ).toBeGreaterThan(0); // LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts. const pt = await page.evaluate(() => { const map = (window as any).__flightMap; const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique 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(); // Pre-v0.11.0 fields still present (regression guard for the existing map specs). await expect(popup).toContainText('Nantes Atlantique Airport'); await expect(popup).toContainText('LFRS'); // New: approximate local time (HH:MM) and generated weather (condition + wind + visibility). const localTime = page.getByTestId('airport-local-time'); await expect(localTime).toBeVisible(); expect((await localTime.textContent()) ?? '').toMatch(/\d{2}:\d{2}/); const weather = page.getByTestId('airport-weather'); await expect(weather).toBeVisible(); const wText = (await weather.textContent()) ?? ''; expect(wText).toMatch(/(Clear|Cloudy|Rain|Fog)/); expect(wText).toMatch(/\d+ kt/); // wind speed in knots }); test('plane markers move while flying, the active route line is highlighted, and the plane popup shows status + route', async ({ page }) => { test.setTimeout(300_000); // generous cap — two short flights at scale 60 plus map waits await page.goto('/'); await waitForAirportCount(page); // Self-heal first: a previous interrupted run may have left the starter plane // mid-flight or at another airport. Let any flight finish, then fly it home so // the test starts from the canonical onboarding state (plane grounded at EDDF). const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter'); expect(foundStarter).toBeDefined(); if (foundStarter!.status === 'flying') { await postSim(page, { timeScale: SCALE }); await expect .poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', { timeout: 120_000, message: 'waiting for an in-flight plane-starter to land', }) .toBe(true); } const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao; if (starterLoc !== HUB) { await flyContract(page, starterLoc, HUB); } // 1) Accept the hook-created contract — before dispatch it must draw a PENDING route line. const contract = await createContract(page, HUB, DEST); const acc = await page.request.post(`/api/contracts/${contract.id}/accept`); expect(acc.status()).toBe(200); // The map updates from the shell's /api/state polls — poll until the pending line lands. await expect .poll(async () => { const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record }>; return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'pending'); }, { timeout: 15_000, message: 'waiting for the pending route line in the routes source' }) .toBe(true); // 2) Dispatch plane-starter and speed up — the marker must appear as a flying feature. const disp = await page.request.post('/api/planes/plane-starter/dispatch', { data: { contractId: contract.id } }); expect(disp.status()).toBe(200); await postSim(page, { timeScale: SCALE }); await expect .poll(async () => { const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record }>; return feats.some((f) => f.properties.id === 'plane-starter' && f.properties.flying === true); }, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }) .toBe(true); // 3) Position CHANGES across two /api/state polls while flying (server computes // lat/lon analytically each tick; the map just renders the polled values). const p1 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!; expect(p1.status).toBe('flying'); await sleep(1500); const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!; expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0); // 4) The route line for this contract is now IN-FLIGHT (solid layer), not pending. await expect .poll(async () => { const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record }>; return feats.some((f) => f.properties.contractId === contract.id && f.properties.kind === 'inflight'); }, { timeout: 15_000, message: 'waiting for the in-flight route line' }) .toBe(true); // 5) Click the flying plane → status popup with model, status, fuel and the route. // Fetch + project in one evaluate so the click lands on a fresh position (the // marker drifts only ~1 px/s of screen space at scale 60). const pt = await page.evaluate(async () => { const m = (window as any).__flightMap; if (!m) return null; const res = await fetch('/api/state', { cache: 'no-store' }); const body = (await res.json()) as StateBody; const p = body.planes.find((x) => x.id === 'plane-starter'); if (!p || p.status !== 'flying') return null; const projected = m.project([p.lon, p.lat]); const rect = m.getContainer().getBoundingClientRect(); return { x: rect.left + projected.x, y: rect.top + projected.y }; }); expect(pt).not.toBeNull(); // the flight (~33 s at scale 60) is still in progress here await page.mouse.click(pt!.x, pt!.y); const popup = page.getByTestId('plane-popup'); await expect(popup).toBeVisible({ timeout: 5_000 }); await expect(popup).toContainText('ATR 72-600'); // catalog label via the snapshot await expect(page.getByTestId('plane-status')).toHaveText('flying'); await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/); const live = (await getState(page)).contracts.find((c) => c.id === contract.id)!; await expect(page.getByTestId('plane-route')).toContainText(`${live.originName} → ${live.destName}`); // 6) Let the flight finish, then fly home so the live instance stays in its // onboarding state (starter grounded at EDDF) for the other specs. 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); await flyContract(page, DEST, HUB); });