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
|
# 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
|
## 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`
|
- **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)
|
- **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;
|
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 {
|
.market-preview {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'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 type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
|
||||||
import { PLANE_TYPES } from '@/lib/game/catalog';
|
import { PLANE_TYPES } from '@/lib/game/catalog';
|
||||||
import { abandonPenalty } from '@/lib/game/penalty';
|
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 [sellPlaneId, setSellPlaneId] = useState<string | null>(null);
|
||||||
const [refuelPlaneId, setRefuelPlaneId] = 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
|
// 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
|
// 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
|
// 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) ??
|
state.planes.find((p) => p.id === refuelPlaneId && p.status === 'available' && p.fuel < p.maxFuel) ??
|
||||||
refuelable[0] ??
|
refuelable[0] ??
|
||||||
null;
|
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).
|
// 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;
|
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
|
Refuel to full
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{actionError && <p data-testid="right-error" className="pane-error">{actionError}</p>}
|
{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",
|
"name": "flight-ops",
|
||||||
"version": "0.17.0",
|
"version": "0.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user