import { test, expect, type Page } from '@playwright/test'; import { authenticate } from './auth'; import { haversineKm } from '../lib/geo'; import { legPhases } from '../lib/sim/movement'; import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog'; // v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and // assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate) // and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The // phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork: // each observation happens inside a window computed from departureSimTime + clockMs. test.beforeEach(async ({ page }) => { await authenticate(page); }); const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses const STARTER = 'plane-starter'; // Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long // enough for the fit + click + popup pipeline, short enough to keep the test fast. const OBS = 30; // Fast-forward through the cruise between the two observations (pure arrival wait). const MID = 1_000; // Self-heal and final arrival waits — no mid-leg observation at this rate. const FAST = 50_000; const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts interface StateBody { simState: { clockMs: number }; planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>; } async function getState(page: Page): Promise { // ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing. for (let attempt = 1; ; attempt++) { const res = await page.request.get('/api/state'); if (res.status() === 200) return (await res.json()) as StateBody; if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`); await page.waitForTimeout(500); } } async function setScale(page: Page, scale: number): Promise { const res = await page.request.post('/api/test/set-timescale', { 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/test/set-timescale`).toBe(scale); } // Leave the live instance in a sane state for the other specs (the fixed world rate). test.afterEach(async ({ page }) => { await setScale(page, WORLD); }); 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); } // Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left // it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second. async function ensureGroundedAt(page: Page, icao: string): Promise { await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; if (p.status === 'available' && p.locationIcao === icao) return 'ok'; if (p.status === 'available') { await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } }); return `flying home from ${p.locationIcao}`; } return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly) }, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok'); } // Frame both endpoints so the marker is rendered wherever it holds on the leg. async function fitToAirports(page: Page, icaos: string[]): Promise { 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); } // Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state // fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames — // used right after dispatch, when the last rendered frame may predate it. async function clickPlane(page: Page, flyingOnly: boolean): Promise { const pt = await page.evaluate(async (onlyFlying: boolean) => { const m = (window as any).__flightMap; if (!m) return null; const deadline = Date.now() + 10_000; for (;;) { let feats: Array<{ properties?: Record; geometry?: { coordinates: number[] } }> = []; try { feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] }); } catch { // map not ready yet — keep polling } const f = feats.find((x) => x.properties?.id === STARTER && (!onlyFlying || x.properties?.flying === true)); if (f && f.geometry) { const projected = m.project(f.geometry.coordinates); const rect = m.getContainer().getBoundingClientRect(); return { x: rect.left + projected.x, y: rect.top + projected.y }; } if (Date.now() >= deadline) return null; await new Promise((r) => setTimeout(r, 100)); } }, flyingOnly); expect(pt, 'plane marker not rendered').not.toBeNull(); // Popups are created per click and never auto-close — remove any stale ones so the testids below // stay unique. await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove())); await page.mouse.click(pt!.x, pt!.y); const popup = page.getByTestId('plane-popup'); await expect(popup).toBeVisible({ timeout: 5_000 }); } test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => { await page.goto('/'); await waitForAirportCount(page); // Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left. await setScale(page, FAST); await ensureGroundedAt(page, HUB); // The exact phase boundaries for this leg — same composition the engine uses (v1.12.0). const fc = (await (await page.request.get('/api/airports')).json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }>; }; const hub = fc.features.find((x) => x.properties.icao === HUB)!; const dest = fc.features.find((x) => x.properties.icao === DEST)!; const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon); const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST); // --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. --- await setScale(page, OBS); const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } }); expect(fly.status()).toBe(200); const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!; await fitToAirports(page, [HUB, DEST]); await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch await expect(page.getByTestId('plane-status')).toHaveText('flying'); await expect(page.getByTestId('plane-phase')).toContainText('taking off'); // --- Landing: fast-forward through the cruise to just before the landing window, then drop back // to OBS so the hold at the destination gate is observable. --- const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs; await expect.poll(async () => { const body = await getState(page); const p = body.planes.find((x) => x.id === STARTER)!; if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry'); return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins }, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true); await setScale(page, OBS); // Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and // before arrival (landingMs ≥ BASE_PHASE_MS > the offset below). const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000); await expect.poll(async () => { const body = await getState(page); if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry'); return body.simState.clockMs >= landingTarget; }, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true); await clickPlane(page, true); await expect(page.getByTestId('plane-status')).toHaveText('flying'); await expect(page.getByTestId('plane-phase')).toContainText('landing'); // --- Arrival: fast-forward until the engine flips it to available at DEST. --- await setScale(page, FAST); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; return p.status === 'available' && p.locationIcao === DEST; }, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true); // Leave the onboarding state for the other specs: starter grounded back at HUB. await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } }); await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === STARTER)!; return p.status === 'available' && p.locationIcao === HUB; }, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true); });