From 09c1d8bcd81cf6e81b29e3b12f64e100ec0cab2b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 15:57:23 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20v0.18.0=20part=201=20=E2=80=94=20IS?= =?UTF-8?q?S-010=20bigger=20airport=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/Map.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/Map.tsx b/components/Map.tsx index e86221e..e88c89a 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -224,9 +224,9 @@ export default function FlightMap({ state, hovered }: MapProps) { type: 'circle', source: AIRPORTS_SOURCE_ID, paint: { - 'circle-radius': 3, + 'circle-radius': 5, // ISS-010 (v0.18.0): bigger — the r=3 dots were hard to click 'circle-color': '#e11d48', - 'circle-stroke-width': 1, + 'circle-stroke-width': 1.5, 'circle-stroke-color': '#ffffff', }, }); @@ -317,7 +317,7 @@ export default function FlightMap({ state, hovered }: MapProps) { source: AIRPORTS_SOURCE_ID, filter: ['any', ['==', ['get', 'icao'], ''], ['==', ['get', 'icao'], '']], paint: { - 'circle-radius': 8, // ring around the base airport circles (r=3) + 'circle-radius': 9, // ring around the base airport circles (r=5) 'circle-color': 'rgba(0, 0, 0, 0)', // transparent fill — stroke-only ring 'circle-stroke-width': 2.5, 'circle-stroke-color': '#facc15', -- 2.54.0 From 3e62d6341869ac90ba7da5cd0432e9cc8bfb7ef4 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 15:57:33 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20v0.18.0=20part=202=20=E2=80=94=20ai?= =?UTF-8?q?rport=20popup=20relocate=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 34 ++++++++++++++++++++++++ app/page.tsx | 6 +++-- components/Map.tsx | 64 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/app/globals.css b/app/globals.css index 4fa72e1..d9dba6f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -68,6 +68,40 @@ body { color: #cbd5e1; } +/* ISS-010 (v0.18.0) — Relocate row inside the airport popup (plain HTML, not React). */ +.popup-relocate { + margin-top: 8px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.popup-relocate select { + font-size: 12px; + padding: 3px 6px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); +} + +.popup-relocate button { + font-size: 12px; + padding: 3px 10px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + cursor: pointer; +} + +.popup-relocate button:disabled { + opacity: 0.5; + cursor: default; +} + +[data-testid='airport-popup-relocate-status'] { + font-size: 11px; + color: #334155; +} + /* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */ .app-shell { display: grid; diff --git a/app/page.tsx b/app/page.tsx index 893be6e..5777fb8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -44,8 +44,10 @@ export default function Home() { return (
- {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines */} - + {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; + ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its + action response into the shared state, exactly like RightPane does */} +
diff --git a/components/Map.tsx b/components/Map.tsx index e88c89a..be2b8c6 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -45,11 +45,14 @@ interface MapProps { // 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; + // ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared + // shell state — same callback RightPane receives, used by the airport popup's Relocate row. + applySnapshot: (snapshot: StateSnapshot) => void; } // 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, hovered }: MapProps) { +export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); // /api/airports is fetched at most once per page life: every styledata event @@ -58,6 +61,12 @@ export default function FlightMap({ state, hovered }: MapProps) { // Latest snapshot + airport coordinate index, so the mount effect's styledata wiring // and the state-change effect can both push data without prop-drilling into closures. const stateRef = useRef(null); + // ISS-010 (v0.18.0): the shell's applySnapshot is stable (useCallback with no deps), but the + // mount effect captures it once — keep a ref so the popup wiring always calls the latest one. + const applySnapshotRef = useRef(applySnapshot); + useEffect(() => { + applySnapshotRef.current = applySnapshot; + }, [applySnapshot]); const airportsIndexRef = useRef | null>(null); const [airportCount, setAirportCount] = useState(null); const [styleId, setStyleId] = useState(DEFAULT_STYLE_ID); @@ -156,16 +165,67 @@ export default function FlightMap({ state, hovered }: MapProps) { // deterministic generated weather — no gameplay effects per PLAN.md. const w = weatherFor(p.icao); const localTime = approxLocalTime(p.lon, Date.now()); - new maplibregl.Popup({ offset: 10 }) + // ISS-010 (v0.18.0): Relocate row — grounded planes from the latest snapshot can be + // repositioned to this airport via POST /api/planes/{id}/fly (pure repositioning). + const st = stateRef.current; + const groundedPlanes = st ? st.planes.filter((pl) => pl.status === 'available') : []; + const planeLabel = (model: string) => st?.catalog.find((c) => c.model === model)?.label ?? model; + const relocateHtml = + groundedPlanes.length > 0 + ? `` + : `No grounded planes to relocate.`; + const popup = new maplibregl.Popup({ offset: 10 }) .setLngLat(f.geometry.coordinates as [number, number]) .setHTML( `
` + `${escapeHtml(p.name)}
${escapeHtml(p.icao)}
` + `${localTime} local (approx)
` + `${w.condition}, wind ${w.windDirDeg}\u00B0 / ${w.windKt} kt, vis ${w.visibilityM} m` + + `` + `
`, ) .addTo(map); + + // Popups are plain HTML, not React — the element only exists after addTo, so wire the + // controls via querySelector (the select enables the button once a plane is picked). + const el = popup.getElement(); + const select = el?.querySelector('[data-testid="airport-popup-plane"]'); + const button = el?.querySelector('[data-testid="airport-popup-relocate"]'); + // The status span is always in the popup HTML (even with no grounded planes), so it + // exists whenever the select/button do. + const statusEl = el?.querySelector('[data-testid="airport-popup-relocate-status"]'); + if (select && button && statusEl) { + select.addEventListener('change', () => { + button.disabled = select.value === ''; + }); + button.addEventListener('click', async () => { + const planeId = select.value; + if (!planeId || button.disabled) return; + button.disabled = true; + statusEl.textContent = 'Relocating\u2026'; + try { + const res = await fetch(`/api/planes/${planeId}/fly`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destIcao: p.icao }), + cache: 'no-store', + }); + const data = (await res.json()) as StateSnapshot & { error?: string }; + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + applySnapshotRef.current(data); // shared shell state updates immediately, like the panes' runAction + statusEl.textContent = 'Relocation started.'; + } catch (err) { + statusEl.textContent = err instanceof Error ? err.message : String(err); + button.disabled = false; // allow retry / picking another plane + } + }); + } }; const onPlaneClick = (e: maplibregl.MapLayerMouseEvent) => { -- 2.54.0 From 008a77e456deee2d9912bced0cc478304f66429c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 16:03:03 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20v0.18.0=20part=203=20=E2=80=94=20ma?= =?UTF-8?q?rket=20row=20to=20relocate=20a=20grounded=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 ++ app/globals.css | 6 ++ components/RightPane.tsx | 76 +++++++++++++++- e2e/relocation.spec.ts | 183 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 5 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 e2e/relocation.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 111e2cd..8f4632a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.18.0 — 2026-09-19 16:02 +- **Relocate a grounded (empty) plane to any airport (ISS-010).** `flyPlane` has allowed pure repositioning since v0.12.0, but the UI only exposed flying for planes that carried cargo — an empty grounded plane had no fly control anywhere. Two new surfaces now drive the EXISTING `POST /api/planes/{id}/fly {destIcao}` endpoint (no API or OpenAPI change): + - **Right-pane market row** (`components/RightPane.tsx`): a fourth `.market-row` with `[data-testid="relocate-plane"]` (grounded planes, "ATR 72-600 — EDDF" labels), `[data-testid="relocate-dest"]` (all airports from a one-time `GET /api/airports` fetch per page life — same promise-ref pattern as Map.tsx — sorted by name for stable e2e selection) and the `[data-testid="relocate-go"]` "Relocate" button, disabled while busy or until both selects have a value. Same effective-selection + `runAction` pattern as the buy/sell/refuel rows (first grounded plane / first airport preselected); the row wraps (`market-row-relocate`) since two selects don't fit one 360 px line + - **Airport popup Relocate row** (`components/Map.tsx`): after the name/ICAO/local-time/weather lines, a `.popup-relocate` block with `[data-testid="airport-popup-plane"]` (grounded planes from the latest polled snapshot at click time) and `[data-testid="airport-popup-relocate"]` "Relocate here" — disabled until a plane is picked; on click it POSTs fly to the clicked airport, pushes the response snapshot through the shell's `applySnapshot` (new Map prop, same callback RightPane receives), and reports success/failure in `[data-testid="airport-popup-relocate-status"]`. With no grounded planes the row shows `[data-testid="airport-popup-no-planes"]` instead. Popups are plain HTML, not React — controls wired via `popup.getElement().querySelector(...)` after `.addTo(map)` +- **Bigger airport markers (ISS-010).** The r=3 dots were hard to click: `airports-circles` paint in `components/Map.tsx` goes from radius 3 / stroke 1 to radius 5 / stroke 1.5, and the hover highlight ring follows (r=8 → r=9). Paint-only — no data or interaction changes +- No server or OpenAPI changes — both surfaces reuse the existing fly endpoint (validations unchanged: 404 unknown plane / 409 flying or insufficient fuel / 400 unknown ICAO, strict unknown-field rejection kept) +- Tests: e2e — new `e2e/relocation.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts `/api/state` shows `flying` with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing +- Verified: `tsc --noEmit` clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack + ## v0.17.0 — 2026-09-19 14:49 - **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css` - **Event messages via snapshot diffing.** No server event feed exists, so new pure `lib/deriveEvents.ts` — `diffSnapshots(prev: StateSnapshot | null, next) → {kind:'info', message}[]` — derives human-readable events from the fields already in `/api/state`: plane landing (`flying→available`, "ATR 72-600 landed at Frankfurt"), package delivery (`loaded→delivered`, "Package delivered: +€X" with the reward), expiry (`open|loaded→expired`, "Package expired: A → B"), new packages (id only in next, "New package available: A → B") and planes bought/sold (plane id set changed, "Bought/Sold {model}"). The shell keeps a `prevRef` and diffs on every snapshot change, pushing each event as an info toast; the first snapshot after load emits nothing (no baseline — no page-load spam) diff --git a/app/globals.css b/app/globals.css index d9dba6f..657085f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -361,6 +361,12 @@ body { padding: 4px 0; } +/* ISS-010 (v0.18.0) — the Relocate row carries TWO selects, so let it wrap instead of + squeezing both below their readable width in the 360 px pane. */ +.market-row-relocate { + flex-wrap: wrap; +} + .market-preview { font-size: 12px; color: #f8fafc; diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 216992b..91c9aeb 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { abandonPenalty } from '@/lib/game/penalty'; @@ -122,6 +123,32 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr const [sellPlaneId, setSellPlaneId] = useState(null); const [refuelPlaneId, setRefuelPlaneId] = useState(null); + // ISS-010 (v0.18.0): Relocate row selections — same "effective selection" pattern as the rows above: + // the user's pick while it stays valid, else a sensible default (first grounded plane / first airport). + const [relocatePlaneId, setRelocatePlaneId] = useState(null); + const [relocateDestIcao, setRelocateDestIcao] = useState(null); + + // ISS-010 (v0.18.0): one-time /api/airports fetch per page life — destination options for the + // Relocate row, sorted by name so e2e selection is stable. Same promise-ref pattern as Map.tsx; + // on failure airportOptions stays null and the Relocate button simply remains disabled. + const [airportOptions, setAirportOptions] = useState | null>(null); + const airportsPromiseRef = useRef> | null>(null); + useEffect(() => { + if (!airportsPromiseRef.current) { + airportsPromiseRef.current = fetch('/api/airports', { cache: 'no-store' }) + .then((res) => { + if (!res.ok) throw new Error(`GET /api/airports → HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((fc) => + fc.features + .map((f) => ({ name: f.properties.name, icao: f.properties.icao })) + .sort((a, b) => a.name.localeCompare(b.name, 'en')), + ); + } + void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); + }, []); + // POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0 // convention) — pushed into the shared state immediately; a 4xx {error} body becomes the // inline pane error and the stale UI stays until the next poll. Buttons are disabled while @@ -182,6 +209,11 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ?? refuelable[0] ?? null; + // ISS-010 (v0.18.0): Relocate — any GROUNDED plane can be repositioned to any airport via the + // existing fly endpoint (pure repositioning, no package needed since v0.12.0). + const relocatePlane = grounded.find((p) => p.id === relocatePlaneId) ?? grounded[0] ?? null; + const relocateDest = + airportOptions === null ? null : airportOptions.find((a) => a.icao === relocateDestIcao) ?? airportOptions[0] ?? null; // Previews computed client-side from the snapshot's catalog + economy constants (v0.10.0). const sellEntry = sellPlane ? catalog.find((e) => e.model === sellPlane.model) : undefined; @@ -422,6 +454,48 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr Refuel to full + + {/* ISS-010 (v0.18.0): relocate a grounded plane — the fly endpoint already allows pure + repositioning, this row just exposes it for planes with nothing on board. */} +
+ + + +
{actionError &&

{actionError}

} diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts new file mode 100644 index 0000000..17414f4 --- /dev/null +++ b/e2e/relocation.spec.ts @@ -0,0 +1,183 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +// v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. +// Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has +// been allowed since v0.12.0 — no API change): the right-pane market row +// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on +// the map (airport-popup-plane / airport-popup-relocate). Marker-size regression is covered by +// the existing e2e/map.spec.ts click tests, not here. + +const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps cleanup flights to seconds of real time +const HUB = 'EDDF'; // Frankfurt Main — the seeded starter plane's home base (lib/game/seed.ts) +// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts: its nearest neighbour is ~78 px +// away at the initial zoom, so a click on its projected center cannot hit another circle. +const POPUP_AIRPORT = { icao: 'LFRS', lon: -1.61073, lat: 47.153198 }; + +interface StateBody { + planes: Array<{ id: string; status: string; locationIcao: string; destIcao: string | null }>; +} + +async function getState(page: Page): Promise { + const res = await page.request.get('/api/state'); + expect(res.ok()).toBeTruthy(); + return (await res.json()) as StateBody; +} + +async function postSim(page: Page, body: Record): Promise { + const res = await page.request.post('/api/sim', { data: body }); + expect(res.ok()).toBeTruthy(); +} + +// Set the time scale and VERIFY from the response that it stuck — if something external ever +// resets the live instance's controls mid-test, fail loudly here instead of timing out later. +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); +} + +// Closest airport to the hub from /api/airports → shortest possible leg (fuel trivial). +async function nearestToHub(page: Page): Promise<{ icao: string }> { + const res = await page.request.get('/api/airports'); + expect(res.ok()).toBeTruthy(); + const fc = (await res.json()) as { features: Array<{ properties: { icao: string; lat: number; lon: number } }> }; + const hub = fc.features.find((f) => f.properties.icao === HUB)!.properties; + let nearest = fc.features.filter((f) => f.properties.icao !== HUB)[0].properties; + for (const f of fc.features) { + if (f.properties.icao === HUB) continue; + const d = Math.hypot(f.properties.lat - hub.lat, f.properties.lon - hub.lon); + const dn = Math.hypot(nearest.lat - hub.lat, nearest.lon - hub.lon); + if (d < dn) nearest = f.properties; + } + return { icao: nearest.icao }; +} + +// Self-heal helper (same pattern as e2e/actions.spec.ts): let any in-flight leg finish and +// reposition plane-starter home via the API — repositioning needs no package (v0.12.0 manual +// flight) — so every test starts from the canonical state even after an interrupted previous run. +async function flyHomeViaApi(page: Page): Promise { + let s = await getState(page); + let plane = s.planes.find((p) => p.id === 'plane-starter')!; + if (plane.status === 'flying' || plane.locationIcao !== HUB) { + // Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×). + await setScale(page, SCALE); + } + if (plane.status === 'flying') { + // While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing + // before deciding whether a repositioning flight is needed. + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status), { timeout: 240_000 }).toBe('available'); + s = await getState(page); + plane = s.planes.find((p) => p.id === 'plane-starter')!; + } + if (plane.locationIcao !== HUB) { + const res = await page.request.post(`/api/planes/${plane.id}/fly`, { data: { destIcao: HUB } }); + expect(res.ok()).toBeTruthy(); // repositioning needs no package (v0.12.0 manual flight) + await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao), { timeout: 240_000 }).toBe(HUB); + } +} + +// Self-heal in CLEANUP (afterEach): the tests deliberately leave plane-starter mid-flight, so +// fast-forward + reposition home here; then restore scale=1 / unpaused for later specs. +test.afterEach(async ({ page }) => { + await flyHomeViaApi(page); + await postSim(page, { timeScale: 1, paused: false }); +}); + +test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file + +test('market row relocates a grounded plane via the right pane', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg + await flyHomeViaApi(page); + + // Full tank before departure — a partially drained plane left by an earlier spec could not + // afford the cleanup round trip. + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.ok()).toBeTruthy(); + + const nearest = await nearestToHub(page); + + await page.goto('/'); + const planeSel = page.getByTestId('relocate-plane'); + await expect(planeSel).toBeVisible({ timeout: 15_000 }); // pane renders once the first snapshot lands + await planeSel.selectOption('plane-starter'); + + // Destination options come from the one-time /api/airports fetch (sorted by name) — selectOption + // waits until the matching option exists, so this doubles as the load wait. + const destSel = page.getByTestId('relocate-dest'); + await destSel.selectOption(nearest.icao); + + const goBtn = page.getByTestId('relocate-go'); + await expect(goBtn).toBeEnabled(); + await goBtn.click(); + + // The click fires the fly POST asynchronously (runAction is fire-and-forget from the test's view), + // so poll until it took effect on the server. + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter')!; + return p.status === 'flying' && p.destIcao === nearest.icao; + }, { timeout: 15_000, message: 'waiting for the relocate click to take effect (status "flying" + dest set)' }).toBe(true); +}); + +test('airport popup offers Relocate options for grounded planes', async ({ page }) => { + test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one leg to LFRS + await flyHomeViaApi(page); + + const refuel = await page.request.post('/api/planes/plane-starter/refuel'); + expect(refuel.ok()).toBeTruthy(); + + await page.goto('/'); + + // Wait until the airport count renders (airports loaded) and the map has a snapshot containing + // the plane — the popup's Relocate row is built from that same latest snapshot. + 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; + }, { timeout: 15_000 }).toBeGreaterThan(0); + await expect.poll(async () => + page.evaluate(() => { + const m = (window as any).__flightMap; + if (!m || !m.getSource('planes')) return false; + try { + return m.querySourceFeatures('planes').some((f: { properties?: Record }) => f.properties?.id === 'plane-starter'); + } catch { + return false; // source not ready yet — keep polling + } + }), + { timeout: 15_000 }, + ).toBe(true); + + // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). + const pt = await page.evaluate(() => { + const map = (window as any).__flightMap; + const p = map.project([POPUP_AIRPORT.lon, POPUP_AIRPORT.lat]); + const rect = map.getContainer().getBoundingClientRect(); + return { x: rect.left + p.x, y: rect.top + p.y }; + }); + await page.mouse.click(pt.x, pt.y); + + const popup = page.getByTestId('airport-popup'); + await expect(popup).toBeVisible(); + await expect(popup).toContainText(POPUP_AIRPORT.icao); + + // The Relocate row: a select of grounded planes + the button (disabled until one is picked). + const planeSel = page.getByTestId('airport-popup-plane'); + await expect(planeSel).toBeVisible(); + await expect(planeSel.locator('option')).toContainText(/ATR 72-600 — EDDF/); + const goBtn = page.getByTestId('airport-popup-relocate'); + await expect(goBtn).toBeDisabled(); // placeholder selected + + await planeSel.selectOption('plane-starter'); + await expect(goBtn).toBeEnabled(); + await goBtn.click(); + + await expect.poll(async () => { + const b = await getState(page); + const p = b.planes.find((x) => x.id === 'plane-starter')!; + return p.status === 'flying' && p.destIcao === POPUP_AIRPORT.icao; + }, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (status "flying" + dest set)' }).toBe(true); +}); diff --git a/package.json b/package.json index b286f75..adc239c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.17.0", + "version": "0.18.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From 8852ae11f2cf6808b376d2371fe0abb9c35b5635 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 16:23:03 +0000 Subject: [PATCH 4/5] test: fix POPUP_AIRPORT scoping in relocation popup e2e --- CHANGELOG.md | 1 + e2e/relocation.spec.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4632a..b316a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - No server or OpenAPI changes — both surfaces reuse the existing fly endpoint (validations unchanged: 404 unknown plane / 409 flying or insufficient fuel / 400 unknown ICAO, strict unknown-field rejection kept) - Tests: e2e — new `e2e/relocation.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts `/api/state` shows `flying` with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing - Verified: `tsc --noEmit` clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack +- **Fix (CI run #50): POPUP_AIRPORT scoping in the popup relocation e2e.** The LFRS marker click passed `POPUP_AIRPORT` into a `page.evaluate(() => …)` callback that runs in the BROWSER, where Node-scope constants don't exist — deterministic `ReferenceError: POPUP_AIRPORT is not defined` (relocation.spec.ts popup test, both attempts of run #50). The constant is now passed as an evaluate argument (`page.evaluate(({lon, lat}) => …, POPUP_AIRPORT)`); the other evaluate in the file only touches browser globals and needed no change. Test-only — no app code touched ## v0.17.0 — 2026-09-19 14:49 - **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css` diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index 17414f4..d69e798 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -151,13 +151,14 @@ test('airport popup offers Relocate options for grounded planes', async ({ page { timeout: 15_000 }, ).toBe(true); - // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). - const pt = await page.evaluate(() => { + // Click the LFRS marker (projected center — same pattern as e2e/map.spec.ts). The constant is + // passed AS AN ARGUMENT: the evaluate callback runs in the browser, where Node-scope values don't exist. + const pt = await page.evaluate(({ lon, lat }) => { const map = (window as any).__flightMap; - const p = map.project([POPUP_AIRPORT.lon, POPUP_AIRPORT.lat]); + const p = map.project([lon, lat]); const rect = map.getContainer().getBoundingClientRect(); return { x: rect.left + p.x, y: rect.top + p.y }; - }); + }, POPUP_AIRPORT); await page.mouse.click(pt.x, pt.y); const popup = page.getByTestId('airport-popup'); -- 2.54.0 From 337eda27435e7fcf240adf86fd209e12d6eaf8eb Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 16:35:19 +0000 Subject: [PATCH 5/5] test: target the plane option specifically in relocation popup e2e --- CHANGELOG.md | 1 + e2e/relocation.spec.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b316a7b..cae7e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Tests: e2e — new `e2e/relocation.spec.ts` (serial mode, SCALE=1000, self-heal fly-home in afterEach like actions.spec.ts): market-row test ensures plane-starter grounded at the hub via API, picks it + the nearest non-hub airport (shortest leg) and asserts `/api/state` shows `flying` with that destIcao after clicking Relocate; popup test clicks the isolated LFRS marker (projected-center pattern from map.spec.ts), asserts the select lists "ATR 72-600 — EDDF" and the button is disabled until a plane is picked, then relocates via the popup. Marker-size regression gate = the existing map.spec.ts click tests keep passing - Verified: `tsc --noEmit` clean; unit suite green locally (no lib/ changes — flyPlane's zero-package case already covered by actions.test.ts); e2e not run locally by design — CI verifies against an isolated stack - **Fix (CI run #50): POPUP_AIRPORT scoping in the popup relocation e2e.** The LFRS marker click passed `POPUP_AIRPORT` into a `page.evaluate(() => …)` callback that runs in the BROWSER, where Node-scope constants don't exist — deterministic `ReferenceError: POPUP_AIRPORT is not defined` (relocation.spec.ts popup test, both attempts of run #50). The constant is now passed as an evaluate argument (`page.evaluate(({lon, lat}) => …, POPUP_AIRPORT)`); the other evaluate in the file only touches browser globals and needed no change. Test-only — no app code touched +- **Fix (CI run #51): strict-mode locator in the popup relocation e2e.** `expect(planeSel.locator('option')).toContainText(…)` resolved to BOTH options (placeholder + plane) → strict mode violation (relocation.spec.ts popup test, both attempts of run #51; the app rendered correctly). The assertion now targets `option[value="plane-starter"]` specifically with `toHaveText`. Test-only — no app code touched ## v0.17.0 — 2026-09-19 14:49 - **Bottom-right toasts for action errors (ISS-008).** Error messages like "insufficient funds: a ATR 72-600 costs 200000, you have 62721" now appear as toasts in the bottom-right corner — new `lib/useToasts.ts` hook (`useToasts() → {toasts, push(kind, msg), dismiss(id)}`, auto-dismiss after ~5 s, queue capped at 4 dropping the oldest; pure core `pushToast` unit-tested) and new `components/Toasts.tsx` `` (fixed container `.toasts`, items `[data-testid="toast"]` with class `toast toast-error|toast-info` + a × dismiss button). Wired into BOTH error paths: `RightPane.runAction`'s catch now calls the shell's `onError` in addition to keeping the inline `[data-testid="right-error"]` pane error, and `LeftPane.applySimControls`'s catch (previously console.error-only) reports "Failed to update time controls (…)" through the same callback. The shell (`app/page.tsx`) owns the hook and renders the stack; CSS in `app/globals.css` diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index d69e798..3e21af9 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -168,7 +168,9 @@ test('airport popup offers Relocate options for grounded planes', async ({ page // The Relocate row: a select of grounded planes + the button (disabled until one is picked). const planeSel = page.getByTestId('airport-popup-plane'); await expect(planeSel).toBeVisible(); - await expect(planeSel.locator('option')).toContainText(/ATR 72-600 — EDDF/); + // Target THE PLANE OPTION specifically — locator('option') also matches the placeholder and + // trips strict mode (CI run #51). + await expect(planeSel.locator('option[value="plane-starter"]')).toHaveText(/ATR 72-600 — EDDF/); const goBtn = page.getByTestId('airport-popup-relocate'); await expect(goBtn).toBeDisabled(); // placeholder selected -- 2.54.0