feat: v0.18.0 part 3 — market row to relocate a grounded plane
CI / build + unit + e2e (pull_request) Failing after 6m17s
CI / build + unit + e2e (pull_request) Failing after 6m17s
This commit is contained in:
@@ -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` `<ToastStack>` (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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [refuelPlaneId, setRefuelPlaneId] = useState<string | null>(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<string | null>(null);
|
||||
const [relocateDestIcao, setRelocateDestIcao] = useState<string | null>(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<Array<{ name: string; icao: string }> | null>(null);
|
||||
const airportsPromiseRef = useRef<Promise<Array<{ name: string; icao: string }>> | 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<GeoJsonFeatureCollection>;
|
||||
})
|
||||
.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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="market-row market-row-relocate">
|
||||
<select
|
||||
data-testid="relocate-plane"
|
||||
aria-label="Plane to relocate"
|
||||
value={relocatePlane?.id ?? ''}
|
||||
onChange={(e) => setRelocatePlaneId(e.target.value || null)}
|
||||
>
|
||||
{grounded.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{planeName(p)} — {p.locationIcao}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
data-testid="relocate-dest"
|
||||
aria-label="Relocation destination airport"
|
||||
value={relocateDest?.icao ?? ''}
|
||||
onChange={(e) => setRelocateDestIcao(e.target.value || null)}
|
||||
>
|
||||
{airportOptions?.map((a) => (
|
||||
<option key={a.icao} value={a.icao}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="relocate-go"
|
||||
className="pane-btn"
|
||||
disabled={busy || relocatePlane === null || relocateDest === null}
|
||||
onClick={() => {
|
||||
if (relocatePlane && relocateDest) {
|
||||
void runAction(`/api/planes/${relocatePlane.id}/fly`, { destIcao: relocateDest.icao });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Relocate
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
||||
|
||||
@@ -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<StateBody> {
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> }) => 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);
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "0.17.0",
|
||||
"version": "0.18.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user