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. // ISS-022/025/027 (v0.22.0): any hover carrying two endpoints ALSO draws the full great-circle // trace between them in a fourth top-most layer (`hover-route`, dashed) — job-list rows, on-board // cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which // additionally gets a distinct green ring in `airports-target-highlight` (ISS-022). 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; lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded lon: number; } interface PackageState { id: string; originName: string; destName: string; reward: number; 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 { 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. 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 { 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 { 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) { // ISS-041: the landing above paused the sim — setScale clears pause so the return leg // fast-forwards instead of taking real-time hours. await setScale(page, SCALE); 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[], ): Promise> { 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 (CI runs #69–#71: strict-equality // timeouts → TypeError on f.geometry.coordinates → filter-found-nothing timeouts). Map to // plain objects IN-PAGE so the getter is evaluated before serialization. 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); } // 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(); } // Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns // features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves // far endpoints (LEMD) off-screen even when they match a layer filter. 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); // 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 traces 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]; } // ISS-022/025/027: the hover-route layer must hold the great-circle trace from origin to dest. // 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 (the 1420 km EDDF→LEMD leg is // 4 segments at z=4 / 5 at z=5) with coordinates quantized to the rendered zoom's grid — up to ~half // a grid step off (~0.001° observed at z≥4, ~0.02° worst case at z=0). A strict single-feature + // exact-coords comparison can therefore never match even when the line is drawn correctly (CI run #69: // all three hover-route timeouts while the trace was visibly rendered), so we accept any number of // segments and require some segment to START within EPS of `from` and some to END within EPS of `to`. // The true endpoints survive clipping verbatim in whichever tile contains them. (CI runs #69–#71: // features could arrive WITHOUT a usable geometry because Playwright's evaluate serialization drops // the lazy prototype getter — not MapLibre artifacts; renderedFeatures now maps to plain objects // in-page.) The helper still filters to real LineString segments and treats "none yet" as keep-polling // instead of throwing inside the poll callback. Call fitToAirports([from, to]) first — rendered // features are viewport-scoped. const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise { const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]); const near = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; await expect.poll(async () => { const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; // Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter // guards against degenerate/zero-length clipped segments and 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 { 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. const startsAtFrom = segs.some((f) => near(f.geometry.coordinates[0], from)); const endsAtTo = segs.some((f) => { const c = f.geometry.coordinates; return near(c[c.length - 1], to); }); return startsAtFrom && endsAtTo; }, { timeout: 15_000, message: `waiting for the hover-route line ${fromIcao} → ${toIcao}` }).toBe(true); } 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); // Fleet rows are
  • and the server id already carries the "plane-" // prefix (run #34: the real testid is plane-plane-starter) — derive it from /api/state, never hardcode. const planes = (await getState(page)).planes; const starterId = planes.find((p) => p.id.endsWith('starter'))?.id ?? (planes.length === 1 ? planes[0].id : undefined); expect(starterId).toBeDefined(); const row = page.locator(`li[data-testid="plane-${starterId}"]`); // The browser's /api/state polls can lag node-side requests right after a fresh page load // (useGameState keeps loading=true until the first success → no fleet rows yet), so poll with // diagnostics: on timeout the message compares what the NODE side sees vs what the BROWSER // last polled, plus the fleet section's HTML — distinguishing server divergence from a render // bug. (The value is returned, not thrown — expect.poll fails immediately on a callback throw // instead of retrying.) await expect .poll(async () => { if ((await row.count()) > 0 && (await row.isVisible())) return 'visible'; const body = await getState(page); const browserSnap = await page.evaluate(() => (window as any).__flightLastSnapshot ?? null); const fleetHtml = await page.evaluate(() => { const el = document.querySelector('[data-testid="fleet-list"]'); if (el) return `fleet-list HTML: ${(el.outerHTML ?? '').slice(0, 300)}`; const pane = document.querySelector('[data-testid="left-pane"]'); return `no fleet-list — left-pane text: ${(pane?.innerText ?? '').slice(0, 200)}`; }); return ( `not visible — node /api/state planes=[${body.planes.map((p) => p.id).join(', ')}] cash=${body.account.cash}; ` + `browser __flightLastSnapshot=${JSON.stringify(browserSnap)}; ${fleetHtml}` ); }, { timeout: 30_000 }) .toBe('visible'); // 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 === starterId; }, { timeout: 15_000, message: `waiting for the planes-highlight layer to show ${starterId}` }).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 }); // Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen // at the default Frankfurt-centered view). await fitToAirports(page, [HUB, DEST]); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-target-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())); // ISS-022 (v0.22.0): the ENTIRE trace is drawn too — a great-circle line from origin to dest, // not just the endpoint rings. await expectHoverRoute(page, HUB, DEST); // And the TARGET airport gets its own distinct-colored ring — only the destination, never the origin. await expect.poll(async () => { const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record }>; return JSON.stringify(feats.map((f) => f.properties.icao).sort()); }, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the destination' }) .toBe(JSON.stringify([DEST])); 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); await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, { timeout: 15_000, message: 'waiting for the hover route trace to clear after mouseleave', }).toBe(0); await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, { timeout: 15_000, message: 'waiting for the target ring to clear after mouseleave', }).toBe(0); }); test('hovering an on-board cargo row draws its trace and keeps the plane halo', 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 // Load a package onto the grounded starter so it shows up in the On board section. 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); await page.goto('/'); await waitForAirportCount(page); const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`); await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); // Both endpoints must be in-viewport for queryRenderedFeatures to see the trace. await fitToAirports(page, [HUB, DEST]); await row.hover(); // ISS-025 (v0.22.0): same full origin→dest trace as job-list rows… await expectHoverRoute(page, HUB, DEST); // …and the package's plane keeps its halo lit (the hover carries planeId). 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, ['hover-route'])).length, { timeout: 15_000, message: 'waiting for the hover route trace to clear after mouseleave', }).toBe(0); await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, { timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); // Leave the instance clean: abandon the loaded package so later specs start with an empty starter. const res = await page.request.post(`/api/packages/${pkg.id}/abandon`); expect(res.status()).toBe(200); }); test('hovering the Relocate section previews the planned route and target airport', 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 await page.goto('/'); await waitForAirportCount(page); const section = page.getByTestId('relocate-section'); await expect(section, 'the Relocate section is visible').toBeVisible({ timeout: 15_000 }); // LeftPane fetches /api/airports on its own (separate from the map header count) — wait for // the destination options to populate before selecting. await expect(page.getByTestId('relocate-dest').locator(`option[value="${DEST}"]`)).toHaveCount(1, { timeout: 15_000 }); // Pick the starter + a known destination explicitly (the effective-selection defaults already do this). await page.getByTestId('relocate-plane').selectOption('plane-starter'); await page.getByTestId('relocate-dest').selectOption(DEST); // Both endpoints must be in-viewport for queryRenderedFeatures to see the trace. await fitToAirports(page, [HUB, DEST]); expect(await renderedFeatures(page, ['hover-route'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0); expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0); // ISS-027 (v0.22.0): hovering the panel with a plane + destination selected previews the flight — // dashed trace from the plane's airport to the destination… await section.hover(); await expectHoverRoute(page, HUB, DEST); // …and rings ONLY the target airport (the origin stays unmarked). 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 ring only the target' }) .toBe(JSON.stringify([DEST])); // …plus the destination's own distinct-colored ring (ISS-022) — same single icao. await expect.poll(async () => { const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record }>; return JSON.stringify(feats.map((f) => f.properties.icao).sort()); }, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the target' }) .toBe(JSON.stringify([DEST])); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, { timeout: 15_000, message: 'waiting for the hover route trace to clear after mouseleave', }).toBe(0); await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, { timeout: 15_000, message: 'waiting for the highlight to clear after mouseleave', }).toBe(0); await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, { timeout: 15_000, message: 'waiting for the target ring 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); // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). 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 === HUB, { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); }); test('hovering an on-board package while its carrier is mid-flight traces from current position', 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); // Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in // both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at // the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window. const hubLonLat = await airportLonLat(page, HUB); await expect .poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter'); if (!p || p.status !== 'flying') return false; return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2; }, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' }) .toBe(true); // Freeze mid-flight so the position is stable while we assert (same pause pattern as the // flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon. await postSim(page, { paused: true }); const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`); await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 }); // Both endpoints must be in-viewport for queryRenderedFeatures to see the trace. await fitToAirports(page, [HUB, DEST]); const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused const destLonLat = await airportLonLat(page, DEST); await row.hover(); // ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination, // and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile // clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS. await expect.poll(async () => { const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>; const segs = feats.filter( (f): f is { 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; const near = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG; const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat)); const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat)); const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat)); return startsAtLive && endsAtDest && noneStartsAtHub; }, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true); await unhover(page); await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, { timeout: 15_000, message: 'waiting for the hover route trace to clear after mouseleave', }).toBe(0); // Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so // flying home afterwards leaves the starter clean 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); // ISS-041: the arrival above paused the sim, so re-set SCALE for this leg (setScale clears pause). 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 === HUB, { timeout: 120_000, message: 'waiting for plane-starter to land back at the hub', }) .toBe(true); });