diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d6ddc..db30490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **Target airport highlighted in a distinct color on job-list / relocation hover (ISS-022).** The destination of any two-endpoint hover now gets its own ring in a NEW top-most `airports-target-highlight` circle layer (`components/Map.tsx`, same `airports` source and r=9 transparent-fill geometry as airports-highlight, but green `#4ade80` stroke width 3 vs the yellow `#facc15` endpoint rings) — added last in `ensureLayers` so it also sits above the hover-route line at its endpoint; `applyHoverRef` filters it to the destination icao for BOTH `'package'` and `'route'` hovers (empty-string sentinel otherwise). airports-highlight itself is untouched, so the existing both-endpoints / target-only assertions stay valid - 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 ## 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 6370cf8..4e1168c 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -174,22 +174,32 @@ async function airportLonLat(page: Page, icao: string): Promise<[number, number] return [f.properties.lon, f.properties.lat]; } -// ISS-022/025/027: the hover-route layer must hold exactly one LineString whose first and last -// coordinates are the two airports' exact dataset coords (greatCircleArc returns them verbatim -// at t=0/t=1, so strict equality is safe). Call fitToAirports([from, to]) first — rendered -// features are viewport-scoped. +// 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. 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: { coordinates: [number, number][] } }>; - if (feats.length !== 1) return false; - const coords = feats[0].geometry.coordinates; - return ( - coords[0][0] === from[0] && - coords[0][1] === from[1] && - coords[coords.length - 1][0] === to[0] && - coords[coords.length - 1][1] === to[1] - ); + if (!feats.length) return false; + // The line may be clipped into several per-tile segments — check the endpoints across all of them. + const startsAtFrom = feats.some((f) => near(f.geometry.coordinates[0], from)); + const endsAtTo = feats.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); }