diff --git a/CHANGELOG.md b/CHANGELOG.md index 137108f..126d85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Tests: unit — new `describe('hoverRouteGeoJson …')` in `lib/mapData.test.ts`: no-hover sentinel → empty; two known airports → one arc with exact dataset endpoints; reversed-input symmetry; missing from/to ICAO → empty (baseline 283 → 288). e2e (`e2e/hover.spec.ts`) — open-package test extended with hover-route endpoint assertions (line geometry first/last coords compared against `/api/airports` lookups) + trace-clear check; new `hovering an on-board cargo row draws its trace and keeps the plane halo` (load via API, hover the row, assert trace endpoints + planes-highlight for that plane, abandon the package afterwards to leave a clean instance); new `hovering the Relocate section previews the planned route and target airport` (selects starter + LEMD via the existing testids, hovers `[data-testid="relocate-section"]`, asserts trace endpoints + target-only ring). New helpers: `airportLonLat(page, icao)` and `expectHoverRoute(page, fromIcao, toIcao)`; all new/extended assertions call `fitToAirports` first since `queryRenderedFeatures` is viewport-scoped - Verified: `tsc --noEmit` clean; unit suite green locally (288); `npx playwright test --list` = 41 tests (was 39, +2 new in hover.spec.ts); e2e not run locally by design — CI verifies against an isolated stack - **Fix: hover-route e2e timeouts (CI run #69).** All three trace tests failed at `expectHoverRoute` with the 15 s "waiting for the hover-route line EDDF → LEMD" timeout although the dashed trace WAS drawn correctly — the helper assumed `queryRenderedFeatures(undefined, {layers:['hover-route']})` returns exactly one feature whose endpoints are bit-identical to the `/api/airports` dataset coords. MapLibre GL JS stores GeoJSON geometry in per-tile INTEGER grids (`Math.round(extent·(x·2^z − tx))`, maplibre-gl 6.10) and clips long lines across tiles, so a 1420 km leg comes back as one feature PER intersecting tile (empirically 4 at z=4 / 5 at z=5 for EDDF→LEMD, verified against the installed build's GeoJSONVT) with endpoints quantized by up to ~half a grid step (~0.001° observed at z≥4, ~0.02° worst case at z=0): both the `feats.length !== 1` guard and the strict `===` coordinate comparisons could never be true. The helper now accepts any number of segments and matches endpoints within 0.05° (~5 km — above worst-case quantization at any zoom, below the distance between any two EU airports): some segment must start at origin and some end at destination; all other assertions unchanged. App side verified correct (no race: `ensureLayers` awaits the airport fetch before creating sources/layers and re-applies hover afterwards; layer/source ids match) — no app changes -- **Fix: hover-route e2e TypeError (CI run #70).** `expectHoverRoute` in `e2e/hover.spec.ts` crashed with `Cannot read properties of undefined (reading 'coordinates')` because MapLibre's `queryRenderedFeatures` can return per-tile artifact features without a usable LineString geometry; a throw inside the `expect.poll` callback fails instantly instead of retrying, killing all three trace tests in ~2 s. The helper now filters to real line segments (LineString with ≥2 coordinates) and treats 'none yet' as keep-polling — no app changes +- **Fix: hover-route e2e — evaluate maplibre geometry in-page before Playwright serialization (CI runs #69–#71).** All three trace tests failed for a serialization reason, not a data one: `renderedFeatures` returned live `GeoJSONFeature` instances from `queryRenderedFeatures`, whose `geometry` is a lazy PROTOTYPE getter — and Playwright's evaluate serialization copies only own enumerable properties (verified empirically), so every feature arrived in test context WITHOUT geometry. Run #69 timed out on the strict length guard before touching it, run #70 threw `TypeError: Cannot read properties of undefined (reading 'coordinates')`, run #71's defensive filter found nothing and timed out. The helper now maps each feature to a plain object inside page.evaluate so the getter is evaluated in-page; the endpoint-matching predicate keeps its degenerate-segment filter as belt-and-braces — no app changes ## v0.21.0 — 2026-09-19 21:01 - **Relocate panel moves to the left pane (ISS-023).** Flying an empty/grounded plane to another airport is "anything with our plane", so it belongs in the left pane, not as a market row on the right. The `.market-row.market-row-relocate` JSX (`relocate-plane` select of grounded planes labeled `{planeName} — {icao}`, `relocate-dest` name-sorted select, `relocate-go` button → `POST /api/planes/{id}/fly {destIcao}`) moved verbatim from `components/RightPane.tsx` into a new "Relocate" `
` in `components/LeftPane.tsx`, inserted between On board and Time. Its state (`relocatePlaneId` / `relocateDestIcao`) and the one-time `/api/airports` fetch (promise-ref pattern, GeoJSON → `{name, icao}[]` sorted by name) moved with it — the relocate-dest select was that fetch's only consumer in RightPane, so nothing else there changed. Busy gating rewired from RightPane's `busy` to LeftPane's own `useGameAction` instance (`actionsBusy`); a grounded-plane filter was added next to the existing `onboard` compute; plane labels reuse the expression already inlined in the Fleet rows. Testids kept byte-identical (`relocate-plane` / `relocate-dest` / `relocate-go`) and the row keeps its `.market-row market-row-relocate` classes (global CSS, works in the 360 px left pane); stale "280 px right pane" comment updated in `app/globals.css`. The map-popup relocation controls (`airport-popup-*`, Map.tsx) are a separate surface fed by the snapshot only — untouched diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index c9f5413..c674557 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -117,12 +117,30 @@ async function waitForAirportCount(page: Page): Promise { } // Rendered features of the given layers in the current viewport, [] until the map exists. -function renderedFeatures(page: Page, layers: string[]) { +function renderedFeatures( + page: Page, + layers: string[], +): Promise> { return page.evaluate((ls: string[]) => { const m = (window as any).__flightMap; if (!m) return []; try { - return m.queryRenderedFeatures(undefined, { layers: ls }); + // 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 []; } @@ -182,11 +200,12 @@ async function airportLonLat(page: Page, icao: string): Promise<[number, number] // 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 run #70 -// additionally showed queryRenderedFeatures can return per-tile artifact features WITHOUT a usable -// geometry at all (f.geometry undefined), so the helper 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. +// 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 { @@ -195,9 +214,9 @@ async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): P 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][] } }>; - // CI run #70: MapLibre can return per-tile artifacts WITHOUT a usable LineString geometry - // (f.geometry undefined). A throw inside an expect.poll callback fails the test instantly - // instead of retrying — so keep only real line segments; "none yet" means keep polling. + // 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,