Files
Flight-Ops/e2e/map-interactions.spec.ts
T
2026-09-19 11:32:19 +00:00

326 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
// v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"):
// expanded airport popup (name + ICAO kept, plus approximate local time and a
// deterministic generated weather), plane markers whose positions come from the
// /api/state polls, each FLYING plane's current leg as a route line (origin →
// dest_icao; grounded planes draw nothing), and the plane status popup. Packages
// are created on a known EDDF→LEMD route via POST /api/test/create-package
// (FLIGHT_TEST_HOOKS=1).
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — ~1420 km from EDDF → ~2.8 h game time ≈ 10 s at scale 1000, a comfortable
// window for the mid-flight click assertions below (at 1000× even Hannover's 33-min leg would
// land in ~2 s — shorter than the map poll + click pipeline). Round-trip burn (~4970 L) fits
// the 6300 L tank, so a full-tank departure always makes it home.
const DEST = 'LEMD';
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top)
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
locationName: string;
destIcao: string | null;
destName: string | null;
lat: number;
lon: number;
}
interface PackageState {
id: string;
originName: string;
destName: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
interface StateBody {
account: { cash: number; reputation: number };
simState: { clockMs: number; timeScale: number; paused: boolean };
planes: PlaneState[];
packages: PackageState[];
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Mirrors haversineKm in lib/geo.ts (same formula → identical floats).
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
async function getState(page: Page): Promise<StateBody> {
const res = await page.request.get('/api/state');
expect(res.status()).toBe(200);
return (await res.json()) as StateBody;
}
async function postSim(page: Page, body: unknown) {
return page.request.post('/api/sim', { data: body });
}
// Set the time scale and VERIFY from the response that it stuck — a silent reset would
// otherwise surface as an unexplained landing-wait timeout.
async function setScale(page: Page, scale: number): Promise<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);
}
// Leave the live instance in a sane state for the other specs (scale 1, running).
test.afterEach(async ({ page }) => {
const res = await postSim(page, { timeScale: 1, paused: false });
expect(res.status()).toBe(200);
});
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs: 24 * 3_600_000 },
});
expect(created.status()).toBe(201);
return (await created.json()) as PackageState;
}
// Load a package onto plane-starter and fly it from → to via the API, then poll at
// scale 1000 until the plane lands at `to`. Same pattern as e2e/actions.spec.ts.
async function loadAndFly(page: Page, fromIcao: string, toIcao: string): Promise<PackageState> {
const pkg = await createPackage(page, fromIcao, toIcao);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: toIcao } });
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
await expect
.poll(async () => {
const body = await getState(page);
const p = body.planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === toIcao;
}, { timeout: 240_000, message: `waiting for plane-starter to land at ${toIcao}` })
.toBe(true);
return pkg;
}
// All features of a GeoJSON source (viewport-independent), [] until the map/source exists.
function sourceFeatures(page: Page, sourceId: string) {
return page.evaluate((srcId: string) => {
const m = (window as any).__flightMap;
if (!m || !m.getSource(srcId)) return [];
try {
return m.querySourceFeatures(srcId);
} catch {
return [];
}
}, sourceId);
}
async function waitForAirportCount(page: Page): Promise<void> {
const countEl = page.getByTestId('airport-count');
await expect.poll(async () => {
const text = (await countEl.textContent()) ?? '';
const m = text.match(/(\d+)/);
return m ? parseInt(m[1], 10) : -1;
}).toBeGreaterThan(0);
}
test('airport popup shows name, ICAO, local time and generated weather', async ({ page }) => {
await page.goto('/');
await waitForAirportCount(page);
// Wait until the map instance exists and airport circles are actually rendered.
await expect.poll(async () =>
page.evaluate(
() => (window as any).__flightMap?.queryRenderedFeatures(undefined, { layers: ['airports-circles'] })?.length ?? 0,
),
{ timeout: 15_000 },
).toBeGreaterThan(0);
// LFRS Nantes Atlantique — same isolated marker as e2e/map.spec.ts.
const pt = await page.evaluate(() => {
const map = (window as any).__flightMap;
const p = map.project([-1.61073, 47.153198]); // LFRS Nantes Atlantique
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();
// Pre-v0.11.0 fields still present (regression guard for the existing map specs).
await expect(popup).toContainText('Nantes Atlantique Airport');
await expect(popup).toContainText('LFRS');
// New: approximate local time (HH:MM) and generated weather (condition + wind + visibility).
const localTime = page.getByTestId('airport-local-time');
await expect(localTime).toBeVisible();
expect((await localTime.textContent()) ?? '').toMatch(/\d{2}:\d{2}/);
const weather = page.getByTestId('airport-weather');
await expect(weather).toBeVisible();
const wText = (await weather.textContent()) ?? '';
expect(wText).toMatch(/(Clear|Cloudy|Rain|Fog)/);
expect(wText).toMatch(/\d+ kt/); // wind speed in knots
});
test('plane markers move while flying and the plane popup shows status + route', async ({ page }) => {
test.setTimeout(600_000); // self-heal from a far airport (~20 s worst case at 1000×) + main flight + cleanup
await page.goto('/');
await waitForAirportCount(page);
// Self-heal first: a previous interrupted run may have left the starter plane
// mid-flight or at another airport. Let any flight finish, then fly it home so
// the test starts from the canonical onboarding state (plane grounded at EDDF).
const foundStarter = (await getState(page)).planes.find((p) => p.id === 'plane-starter');
expect(foundStarter).toBeDefined();
if (foundStarter!.status === 'flying') {
await setScale(page, SCALE);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status === 'available', {
timeout: 240_000,
message: 'waiting for an in-flight plane-starter to land',
})
.toBe(true);
}
const starterLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
if (starterLoc !== HUB) {
await loadAndFly(page, starterLoc, HUB);
}
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank,
// so a partially drained plane left by an earlier spec could not make it home.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
// 1) Load the hook-created package — a GROUNDED plane draws no route line yet.
const pkg = await createPackage(page, HUB, DEST);
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
expect(load.status()).toBe(200);
// The map updates from the shell's /api/state polls — poll until the snapshot lands.
await expect
.poll(async () => {
const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
return feats.some((f) => f.properties.id === 'plane-starter');
}, { timeout: 15_000, message: 'waiting for the plane feature in the planes source' })
.toBe(true);
// A GROUNDED plane draws no route line. Poll (not one-shot): if self-heal just flew the
// plane home, the map's last poll may still show that flight's stale line until its next
// ~1 s tick — wait for it to clear rather than racing the poll loop.
await expect
.poll(async () => {
const feats = (await sourceFeatures(page, 'routes')) as Array<{ properties: Record<string, unknown> }>;
return !feats.some((f) => f.properties.planeId === 'plane-starter');
}, { timeout: 15_000, message: 'waiting for the grounded plane to draw no route line' })
.toBe(true);
// 2) Fly the plane and speed up — its current leg must appear as a route line.
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
await postSim(page, { timeScale: SCALE });
await expect
.poll(async () => {
const feats = (await sourceFeatures(page, 'planes')) as Array<{ properties: Record<string, unknown> }>;
return feats.some((f) => f.properties.id === 'plane-starter' && f.properties.flying === true);
}, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' })
.toBe(true);
// 3) Position CHANGES across two /api/state polls while flying (server computes
// lat/lon analytically each tick; the map just renders the polled values).
const p1 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
expect(p1.status).toBe('flying');
await sleep(1500);
const p2 = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
expect(haversineKm(p1.lat, p1.lon, p2.lat, p2.lon)).toBeGreaterThan(0);
// (No map-side assertion on the drawn leg here: MapLibre's querySourceFeatures returns
// tile-simplified geometry at low zoom, so coordinate counts from it are unreliable —
// the v0.12.2 curved-arc check failed in CI and live while the arc rendered correctly.
// The arc itself stays covered by unit tests: lib/mapData.test.ts.)
// 4) Click the flying plane → status popup with model, status, fuel and the leg.
// Freeze the sim so the click can't miss a fast-moving marker: at scale 1000 the rendered
// marker lags our fresh /api/state fetch by up to one map poll (~1 s) — tens of px of drift
// on this leg. Pausing makes the position static; we resume right after the popup checks.
await postSim(page, { paused: true });
let prevPos: { lat: number; lon: number } | null = null;
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false;
if (prevPos && prevPos.lat === p.lat && prevPos.lon === p.lon) return true;
prevPos = { lat: p.lat, lon: p.lon };
return false;
}, { timeout: 15_000, message: 'waiting for the paused clock to freeze the plane position' })
.toBe(true);
// Click the RENDERED marker position, not a fresh /api/state fetch. With the sim paused
// above the server's analytic position is static — but the map renders from its own ~1 s
// poll, so right after the freeze confirmation the last rendered frame can still be up to
// one poll interval stale (tens of px at scale 1000 on this leg) and a click projected
// from a fresh fetch misses the small circle layer (CI run #37). Poll for the plane's
// rendered feature instead and project ITS coordinates — exactly what the map draws.
// Find + project in one evaluate so nothing can change between the two steps.
const pt = await page.evaluate(async () => {
const m = (window as any).__flightMap;
if (!m) return null;
const deadline = Date.now() + 5_000;
for (;;) {
let feats: Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }> = [];
try {
feats = m.queryRenderedFeatures(undefined, { layers: ['planes-flying', 'planes-grounded'] });
} catch {
// map not ready yet — keep polling
}
const f = feats.find((x) => x.properties?.id === 'plane-starter');
if (f && f.geometry) {
const projected = m.project(f.geometry.coordinates);
const rect = m.getContainer().getBoundingClientRect();
return { x: rect.left + projected.x, y: rect.top + projected.y };
}
if (Date.now() >= deadline) return null;
await new Promise((r) => setTimeout(r, 100));
}
});
expect(pt, 'plane marker not rendered after pause').not.toBeNull();
await page.mouse.click(pt!.x, pt!.y);
const popup = page.getByTestId('plane-popup');
await expect(popup).toBeVisible({ timeout: 5_000 });
await expect(popup).toContainText('ATR 72-600'); // catalog label via the snapshot
await expect(page.getByTestId('plane-status')).toHaveText('flying');
await expect(page.getByTestId('plane-fuel')).toHaveText(/\d+ \/ \d+ L/);
const live = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!;
// The popup's route line is the plane's own leg: origin name → destination name.
await expect(page.getByTestId('plane-route')).toContainText(`${live.locationName} → ${live.destName}`);
// Resume before step 5 — the landing wait needs a running clock.
await postSim(page, { timeScale: SCALE, paused: false });
// 5) Let the flight finish, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs. The loaded
// package is delivered on arrival (in-deadline).
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
return !!p && p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 120_000, message: `waiting for plane-starter to land at ${DEST}` })
.toBe(true);
expect((await getState(page)).packages.find((x) => x.id === pkg.id)?.status).toBe('delivered');
await loadAndFly(page, DEST, HUB);
});