276 lines
14 KiB
TypeScript
276 lines
14 KiB
TypeScript
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 left-pane Relocate section (ISS-023, v0.21.0)
|
||
// (relocate-plane / relocate-dest / relocate-go) and the Relocate row inside the airport popup on
|
||
// the map (airport-popup-plane / airport-popup-relocate). Since v0.49.0 (ISS-096) the popup row also
|
||
// works for FLYING planes — it then appends to POST /api/planes/{id}/queue instead of flying now.
|
||
// Marker-size regression is covered by the existing e2e/map.spec.ts click tests, not here.
|
||
|
||
const SCALE = 1000; // test rate for fast-forwarding legs — keeps cleanup flights to seconds of real time
|
||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||
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; maxFuel: number; locationIcao: string; destIcao: string | null; queue: string[] }>;
|
||
}
|
||
|
||
async function getState(page: Page): Promise<StateBody> {
|
||
// ISS-127: /api/state intermittently 500s under scale-1000 dind load; a throw here would
|
||
// instantly fail any expect.poll callback, so retry briefly instead.
|
||
for (let attempt = 1; ; attempt++) {
|
||
const res = await page.request.get('/api/state');
|
||
if (res.status() === 200) return (await res.json()) as StateBody;
|
||
if (attempt >= 3) throw new Error(`/api/state returned ${res.status()} after 3 attempts`);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): top the starter's tank back to full — replaces the removed /refuel
|
||
// endpoint (ISS-082). Reads maxFuel from the snapshot so no catalog mirror is needed.
|
||
async function setFuelFull(page: Page, planeId: string): Promise<void> {
|
||
const s = await getState(page);
|
||
const p = s.planes.find((x) => x.id === planeId)!;
|
||
expect((await page.request.post('/api/test/set-fuel', { data: { planeId, fuel: p.maxFuel } })).status()).toBe(200);
|
||
}
|
||
|
||
// Test hook (FLIGHT_TEST_HOOKS=1): set the world rate 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/test/set-timescale', { 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/test/set-timescale`).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) {
|
||
// Make sure the world rate is back at SCALE so the return leg fast-forwards.
|
||
await setScale(page, SCALE);
|
||
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 the fixed world rate for later specs.
|
||
test.afterEach(async ({ page }) => {
|
||
await flyHomeViaApi(page);
|
||
await setScale(page, WORLD);
|
||
});
|
||
|
||
test.describe.configure({ mode: 'serial' }); // shared live DB — order matters within this file
|
||
|
||
test('left pane Relocate section relocates a grounded plane', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg
|
||
await flyHomeViaApi(page);
|
||
|
||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
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);
|
||
|
||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
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). 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([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');
|
||
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();
|
||
// 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
|
||
|
||
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);
|
||
});
|
||
|
||
test('airport popup Relocate queues a leg for a flying plane', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal (worst case ~20 s at 1000×) + one short leg, fast-forwarded in cleanup
|
||
await flyHomeViaApi(page);
|
||
|
||
// Full tank before departure — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
// ISS-096: the popup must offer Relocate for a FLYING plane too. Dispatch the starter on a leg via
|
||
// the API, then slow the sim to 1x so the leg cannot land during the UI steps (at 1x even the
|
||
// shortest leg takes minutes of real time) — "still flying its original destination" below is
|
||
// deterministic regardless of how fast the UI moves.
|
||
const nearest = await nearestToHub(page);
|
||
expect(nearest.icao).not.toBe(POPUP_AIRPORT.icao); // popup click target must differ from the active leg
|
||
await setScale(page, 1);
|
||
|
||
const flyRes = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: nearest.icao } });
|
||
expect(flyRes.ok()).toBeTruthy();
|
||
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 API dispatch to take effect (status "flying" + dest set)' }).toBe(true);
|
||
|
||
await page.goto('/');
|
||
|
||
// Same map-ready waits as the grounded popup test above: airport count rendered + a snapshot with
|
||
// the plane in the planes source — 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);
|
||
|
||
const pt = await page.evaluate(({ lon, lat }) => {
|
||
const map = (window as any).__flightMap;
|
||
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');
|
||
await expect(popup).toBeVisible();
|
||
await expect(popup).toContainText(POPUP_AIRPORT.icao);
|
||
|
||
// The Relocate row now lists ALL planes (ISS-096) — the flying starter is selectable even though it
|
||
// is not grounded; while flying its option shows the DEPARTURE airport.
|
||
const planeSel = page.getByTestId('airport-popup-plane');
|
||
await expect(planeSel).toBeVisible();
|
||
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
|
||
|
||
await planeSel.selectOption('plane-starter');
|
||
await expect(goBtn).toBeEnabled();
|
||
await goBtn.click();
|
||
|
||
// The click fires the queue POST asynchronously — poll until it took effect: the leg is APPENDED to
|
||
// the flight queue while the plane keeps flying its original destination.
|
||
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 && p.queue.includes(POPUP_AIRPORT.icao);
|
||
}, { timeout: 15_000, message: 'waiting for the popup relocate click to take effect (queue contains LFRS, still flying original dest)' }).toBe(true);
|
||
});
|