From 3b6506832f827369b1fd590d2d50fd50adca8adb Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 09:37:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.15.0=20=E2=80=94=20ISS-002=20hoverin?= =?UTF-8?q?g=20a=20plane=20or=20package=20in=20the=20side=20panels=20highl?= =?UTF-8?q?ights=20it=20on=20the=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + app/page.tsx | 12 +- components/LeftPane.tsx | 14 ++- components/Map.tsx | 90 ++++++++++++++- components/RightPane.tsx | 26 ++++- components/hoverTypes.ts | 7 ++ e2e/hover.spec.ts | 240 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 8 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 components/hoverTypes.ts create mode 100644 e2e/hover.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e9182f..60d1f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v0.15.0 — 2026-09-19 09:35 +- **Hover visualization from the side panels (ISS-002).** Hovering a fleet row in the left pane, an "On board" block (including its cargo rows) or an open-package row in the right pane now highlights the matching object on the map. The shared `HoverTarget` type (`components/hoverTypes.ts`: `{kind:'plane', id}` | `{kind:'package', originIcao, destIcao}`) is owned by the shell as a single `useState` and prop-drilled down — panes report via an `onHover` callback, `` consumes it. No React contexts introduced; the established prop-drilling pattern is kept +- **Three top-most highlight layers** (`components/Map.tsx`, added inside idempotent `ensureLayers()` so they survive every `setStyle`): `planes-highlight` (circle over both plane layers, filter `['==',['get','id'],]`, r=10, #38bdf8 fill + white stroke), `routes-highlight` (line over `routes-legs`, filter on the leg's `planeId`, width 6, #ef4444) and `airports-highlight` (circle ring over `airports-circles`, filter matching the hovered package's two ICAOs via `['any', …]`, r=8 stroke-only). All start filtered to match nothing — an empty-string sentinel no feature can have. Consumption follows the existing ref + effect pattern: a `[hovered]` effect calls `map.setFilter(...)` on the three layers (guarded by `getLayer`), and `ensureLayers` re-applies the current filter at its end so a hover outliving a style switch stays highlighted +- e2e — new `e2e/hover.spec.ts` (3 tests, same SCALE/self-heal/`window.__flightMap` conventions as the other specs): fleet-row hover → `planes-highlight` renders exactly plane-starter, clears on mouseleave; open-package hover → `airports-highlight` shows both endpoint ICAOs (hook-created EDDF→LEMD package), clears on mouseleave; on-board block of a flying plane → `routes-highlight` shows the leg's planeId (sim paused mid-flight so the ~10 s leg can't land under the assertions, then resumed + fly-home cleanup) +- Verified: `tsc --noEmit` clean; unit suite **238/238** green locally (no lib changes); e2e not run locally by design — CI verifies against an isolated stack + ## v0.14.0 — 2026-09-19 08:45 - **Time-scale slider reworked to a 10-step ladder up to 1000× (ISS-006).** The old power-of-two ladder `1/2/4/8/16/32/64` is replaced by the discrete values `1, 2, 5, 10, 25, 50, 100, 250, 500, 1000` (each step roughly doubles the speed). The server clamp range widens from `[1, 64]` to `[1, 1000]` (`MAX_TIME_SCALE` in `lib/sim/engine.ts`, enforced by `clampTimeScale`; out-of-range values are still clamped, never rejected), and the OpenAPI spec's `timeScale` bounds + description follow. The slider mapping helpers in `lib/timeScale.ts` now place off-ladder scales at the nearest ladder step **by log distance** (the old `log2` formula assumed a pure power-of-two ladder); round-trip, monotonicity and endpoint-exactness are preserved - **E2e suite runs at the fastest time.** Every spec's fast-forward scale moves from 60× to the new top of 1000× (`sim`, `actions`, `right-pane`, `map-interactions`), so flights/expiries resolve in seconds instead of minutes. The map mid-flight test (`map-interactions.spec.ts`) needed a longer leg to keep its click assertions inside the flight window at 1000×: destination moves EDDV (Hannover, ~2 s real) → LEMD (Madrid–Barajas, ~10 s real), with a full-tank refuel before departure so the ~4970 L round trip always fits the 6300 L tank. The slider e2e (`panes.spec.ts`) now asserts End→`1000×` / Home→`1×`, and the clamp tests post `2000` → expect `1000` (500 is a valid ladder value, so it passes through unclamped) diff --git a/app/page.tsx b/app/page.tsx index 776a188..105c63d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,10 @@ 'use client'; import dynamic from 'next/dynamic'; +import { useState } from 'react'; import LeftPane from '@/components/LeftPane'; import RightPane from '@/components/RightPane'; +import type { HoverTarget } from '@/components/hoverTypes'; import { useGameState } from '@/lib/useGameState'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); @@ -13,12 +15,16 @@ export default function Home() { // lets a pane push an action endpoint's full response snapshot in immediately. const { state, loading, error, refresh, applySnapshot } = useGameState(1000); + // ISS-002 (v0.15.0): the side panels' hover target — shared here so hovering a row in + // either pane highlights the matching plane/route/airports on the map. + const [hovered, setHovered] = useState(null); + return (
- + {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */} - - + +
); } diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index db5c22b..6d2868a 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -5,6 +5,7 @@ import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; +import type { HoverTarget } from './hoverTypes'; interface LeftPaneProps { state: StateSnapshot | null; @@ -12,6 +13,8 @@ interface LeftPaneProps { error: string | null; // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. onRefresh: () => void; + // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. + onHover: (target: HoverTarget | null) => void; } // Discrete ladder slider (v0.9.2, values reworked in v0.14.0): position t ∈ [0,1] → nearest @@ -42,7 +45,7 @@ export function pendingScaleConfirmed(pendingScale: number | null, serverTimeSca return pendingScale !== null && serverTimeScale === pendingScale; } -export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneProps) { +export default function LeftPane({ state, loading, error, onRefresh, onHover }: LeftPaneProps) { const [busy, setBusy] = useState(false); // POST /api/sim, then refetch the full snapshot immediately so the pane reflects the new @@ -189,7 +192,14 @@ export default function LeftPane({ state, loading, error, onRefresh }: LeftPaneP ) : (
    {state.planes.map((p) => ( -
  • + // ISS-002: hovering a fleet row highlights the plane on the map. +
  • onHover({ kind: 'plane', id: p.id })} + onMouseLeave={() => onHover(null)} + >
    {PLANE_TYPES[p.model]?.name ?? p.model} {p.status} diff --git a/components/Map.tsx b/components/Map.tsx index 05083d6..e86221e 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -7,6 +7,7 @@ import pkg from '../package.json'; import { DEFAULT_STYLE_ID, STYLES, STYLE_STORAGE_KEY, isStyleId, localAssetsAvailable, styleUrlFor } from '../lib/mapStyles'; import type { GeoJsonFeatureCollection } from '../lib/airports'; import type { StateSnapshot } from '../lib/game/state'; +import type { HoverTarget } from './hoverTypes'; import { planesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { weatherFor } from '../lib/weather'; import { approxLocalTime } from '../lib/localTime'; @@ -27,6 +28,11 @@ const PLANES_GROUNDED_LAYER_ID = 'planes-grounded'; const PLANES_FLYING_LAYER_ID = 'planes-flying'; const ROUTES_SOURCE_ID = 'routes'; const ROUTES_LEGS_LAYER_ID = 'routes-legs'; +// ISS-002 (v0.15.0): top-most hover-highlight layers, added after the base layers so they +// render above them. Each starts filtered to match nothing; applyHoverRef drives the filters. +const PLANES_HIGHLIGHT_LAYER_ID = 'planes-highlight'; +const ROUTES_HIGHLIGHT_LAYER_ID = 'routes-highlight'; +const AIRPORTS_HIGHLIGHT_LAYER_ID = 'airports-highlight'; function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); @@ -36,11 +42,14 @@ interface MapProps { // The shell's polled /api/state snapshot (v0.11.0) — drives the plane markers and // route lines. Null until the first successful poll. state?: StateSnapshot | null; + // ISS-002 (v0.15.0): the side panels' hover target — highlights the matching plane + // (+ its route leg if flying) or a package's endpoint airports. Null = nothing hovered. + hovered?: HoverTarget | null; } // Named FlightMap (not Map): a component called `Map` would shadow the global // Map constructor inside its own body, breaking `new Map(...)` for the airport index. -export default function FlightMap({ state }: MapProps) { +export default function FlightMap({ state, hovered }: MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); // /api/airports is fetched at most once per page life: every styledata event @@ -74,6 +83,38 @@ export default function FlightMap({ state }: MapProps) { applyDynamicRef.current(); }, [state]); + // ISS-002 (v0.15.0): hover highlight — same ref + effect pattern as the snapshot above. + // No-op until the layers exist; ensureLayers re-applies the current filter after every + // styledata re-establish, so a hover that outlives a style switch stays highlighted. + const hoveredRef = useRef(null); + const applyHoverRef = useRef<() => void>(() => {}); + applyHoverRef.current = () => { + const map = mapRef.current; + if (!map) return; + const h = hoveredRef.current; + // The empty-string sentinel matches nothing: no feature has id/icao ''. + const planeId = h && h.kind === 'plane' ? h.id : ''; + if (map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) { + map.setFilter(PLANES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'id'], planeId]); + } + if (map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) { + map.setFilter(ROUTES_HIGHLIGHT_LAYER_ID, ['==', ['get', 'planeId'], planeId]); + } + const originIcao = h && h.kind === 'package' ? h.originIcao : ''; + const destIcao = h && h.kind === 'package' ? h.destIcao : ''; + if (map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) { + map.setFilter( + AIRPORTS_HIGHLIGHT_LAYER_ID, + ['any', ['==', ['get', 'icao'], originIcao], ['==', ['get', 'icao'], destIcao]], + ); + } + }; + + useEffect(() => { + hoveredRef.current = hovered ?? null; + applyHoverRef.current(); + }, [hovered]); + useEffect(() => { if (!containerRef.current) return; @@ -240,6 +281,49 @@ export default function FlightMap({ state }: MapProps) { }, }); } + // ISS-002 (v0.15.0): hover-highlight layers — added last so they sit top-most over + // both plane layers / the route line / the airport circles. Each starts filtered to + // match nothing; applyHoverRef sets the real filter on hover and re-applies it here. + if (!map.getLayer(PLANES_HIGHLIGHT_LAYER_ID)) { + map.addLayer({ + id: PLANES_HIGHLIGHT_LAYER_ID, + type: 'circle', + source: PLANES_SOURCE_ID, + filter: ['==', ['get', 'id'], ''], + paint: { + 'circle-radius': 10, // halo well above the base circles (3 grounded / 6 flying) + 'circle-color': '#38bdf8', + 'circle-stroke-width': 2.5, + 'circle-stroke-color': '#ffffff', + }, + }); + } + if (!map.getLayer(ROUTES_HIGHLIGHT_LAYER_ID)) { + map.addLayer({ + id: ROUTES_HIGHLIGHT_LAYER_ID, + type: 'line', + source: ROUTES_SOURCE_ID, + filter: ['==', ['get', 'planeId'], ''], + paint: { + 'line-color': '#ef4444', // red — distinct from the amber routes-legs line + 'line-width': 6, + }, + }); + } + if (!map.getLayer(AIRPORTS_HIGHLIGHT_LAYER_ID)) { + map.addLayer({ + id: AIRPORTS_HIGHLIGHT_LAYER_ID, + type: 'circle', + source: AIRPORTS_SOURCE_ID, + filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']], + paint: { + 'circle-radius': 8, // ring around the base airport circles (r=3) + 'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring + 'circle-stroke-width': 2.5, + 'circle-stroke-color': '#facc15', + }, + }); + } // Layer-scoped listeners survive setStyle (they filter by layer id at event // time), so off+on with the same reference re-wires without duplicating. map.off('click', AIRPORTS_LAYER_ID, onAirportClick); @@ -248,8 +332,10 @@ export default function FlightMap({ state }: MapProps) { map.on('click', PLANES_GROUNDED_LAYER_ID, onPlaneClick); map.off('click', PLANES_FLYING_LAYER_ID, onPlaneClick); map.on('click', PLANES_FLYING_LAYER_ID, onPlaneClick); - // Sources were (re)created above — push the latest game state into them. + // Sources were (re)created above — push the latest game state into them, and re-apply + // the current hover filter to the (re-created) highlight layers. applyDynamicRef.current(); + applyHoverRef.current(); }; if (map.isStyleLoaded()) void ensureLayers(); diff --git a/components/RightPane.tsx b/components/RightPane.tsx index fa4dea3..900117a 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -5,6 +5,7 @@ import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; +import type { HoverTarget } from './hoverTypes'; interface RightPaneProps { state: StateSnapshot | null; @@ -12,6 +13,9 @@ interface RightPaneProps { // Push the full snapshot returned by an action endpoint into the shared shell state (v0.10.0), // so the pane updates on click without waiting for the next poll tick. applySnapshot: (snapshot: StateSnapshot) => void; + // ISS-002 (v0.15.0): report row hovers to the shell so the map can highlight the plane or + // the package's endpoint airports. + onHover: (target: HoverTarget | null) => void; } const planeName = (p: PlaneState): string => PLANE_TYPES[p.model]?.name ?? p.model; @@ -100,7 +104,7 @@ function LoadControls({ ); } -export default function RightPane({ state, loading, applySnapshot }: RightPaneProps) { +export default function RightPane({ state, loading, applySnapshot, onHover }: RightPaneProps) { const [busy, setBusy] = useState(false); // Inline error from a failed action — auto-clears on the next successful poll (the shell's // snapshot prop changes every tick) or the next successful action. @@ -192,7 +196,14 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr {open.map((pkg) => { const eligible = loadEligiblePlanes(state, pkg); return ( -
  • + // ISS-002: hovering an open package highlights its two endpoint airports on the map. +
  • onHover({ kind: 'package', originIcao: pkg.originIcao, destIcao: pkg.destIcao })} + onMouseLeave={() => onHover(null)} + >
    {pkg.originName} → {pkg.destName} {eligible.length > 0 ? ( @@ -232,7 +243,16 @@ export default function RightPane({ state, loading, applySnapshot }: RightPanePr (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'), ); return ( -
    + // ISS-002: hovering the on-board block (including its cargo rows) highlights the plane. + // Handlers live only on the outer div — React's enter/leave don't fire when moving to a + // child, so this covers the whole block without flicker between sub-elements. +
    onHover({ kind: 'plane', id: plane.id })} + onMouseLeave={() => onHover(null)} + >
    {planeName(plane)} {plane.status === 'flying' ? ( diff --git a/components/hoverTypes.ts b/components/hoverTypes.ts new file mode 100644 index 0000000..ec01470 --- /dev/null +++ b/components/hoverTypes.ts @@ -0,0 +1,7 @@ +// ISS-002 — what a side-panel hover highlights on the map (v0.15.0): a plane marker +// (+ its route leg if flying) or both endpoint airports of an open package. The shell +// owns this state and passes it to ; panes report it via their onHover callback. + +export type HoverTarget = + | { kind: 'plane'; id: string } + | { kind: 'package'; originIcao: string; destIcao: string }; diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts new file mode 100644 index 0000000..f626f5b --- /dev/null +++ b/e2e/hover.spec.ts @@ -0,0 +1,240 @@ +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. + +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; +} + +interface PackageState { + id: string; + originName: string; + destName: string; + reward: number; + status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned'; + planeId: string | null; +} + +interface StateBody { + 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. +async function setScale(page: Page, scale: number): Promise { + const res = await page.request.post('/api/sim', { 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/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) { + 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[]) { + return page.evaluate((ls: string[]) => { + const m = (window as any).__flightMap; + if (!m) return []; + try { + return m.queryRenderedFeatures(undefined, { layers: ls }); + } 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(); +} + +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); + + const row = page.locator('li[data-testid="plane-starter"]'); + await expect(row).toBeVisible({ timeout: 15_000 }); + + // 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 === '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, ['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 }); + + expect(await renderedFeatures(page, ['airports-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())); + + 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); +}); + +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); + + 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); +}); diff --git a/package.json b/package.json index 7e73547..eeb2292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.14.0", + "version": "0.15.0", "private": true, "scripts": { "dev": "next dev -p 3000",