Files
Flight-Ops/e2e/phases.spec.ts
T
2026-09-28 10:08:07 +00:00

206 lines
10 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';
import { authenticate } from './auth';
import { haversineKm } from '../lib/geo';
import { legPhases } from '../lib/sim/movement';
import { PLANE_TYPES, STARTER_MODEL } from '../lib/game/catalog';
// v1.12.0 — takeoff & landing phases on the marker: dispatch the starter plane on a known leg and
// assert the popup shows 'taking off' shortly after departure (the plane holds at the origin gate)
// and 'landing' near arrival (holding at the destination gate), then 'available' once landed. The
// phase boundaries come from the SAME legPhases composition the engine uses, so no timing guesswork:
// each observation happens inside a window computed from departureSimTime + clockMs.
test.beforeEach(async ({ page }) => { await authenticate(page); });
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
const DEST = 'LEMD'; // ~1420 km — the same leg map-interactions.spec.ts uses
const STARTER = 'plane-starter';
// Observation rate: a phase window (600–1200 game-s) lasts 20–40 real seconds at this scale — long
// enough for the fit + click + popup pipeline, short enough to keep the test fast.
const OBS = 30;
// Fast-forward through the cruise between the two observations (pure arrival wait).
const MID = 1_000;
// Self-heal and final arrival waits — no mid-leg observation at this rate.
const FAST = 50_000;
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
interface StateBody {
simState: { clockMs: number };
planes: Array<{ id: string; status: string; locationIcao: string; departureSimTime: number | null }>;
}
async function getState(page: Page): Promise<StateBody> {
// ISS-127: /api/state intermittently 500s under dind load — retry briefly instead of failing.
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);
}
}
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);
}
// Leave the live instance in a sane state for the other specs (the fixed world rate).
test.afterEach(async ({ page }) => {
await setScale(page, WORLD);
});
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);
}
// Self-heal: wait until the starter is grounded at HUB, flying it back first if another spec left
// it elsewhere. Runs at FAST — a cross-Europe hop lands in well under a second.
async function ensureGroundedAt(page: Page, icao: string): Promise<void> {
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
if (p.status === 'available' && p.locationIcao === icao) return 'ok';
if (p.status === 'available') {
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: icao } });
return `flying home from ${p.locationIcao}`;
}
return p.status; // flying or repairing — keep waiting (FAST makes both finish quickly)
}, { timeout: 120_000, message: 'waiting for the starter plane to be grounded at ' + icao }).toBe('ok');
}
// Frame both endpoints so the marker is rendered wherever it holds on the leg.
async function fitToAirports(page: Page, icaos: string[]): Promise<void> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const points = icaos.map((icao) => {
const f = fc.features.find((x) => x.properties.icao === icao);
if (!f) throw new Error(`airport ${icao} not found in /api/airports`);
return [f.properties.lon, f.properties.lat] as [number, number];
});
await page.evaluate((pts: [number, number][]) => {
const m = (window as any).__flightMap;
if (!m) throw new Error('map not ready');
const lons = pts.map((p) => p[0]);
const lats = pts.map((p) => p[1]);
// duration 0 → instant camera jump, no animation to wait out.
m.fitBounds(
[
[Math.min(...lons), Math.min(...lats)],
[Math.max(...lons), Math.max(...lats)],
],
{ padding: 40, duration: 0 },
);
}, points);
}
// Click the RENDERED marker position (the map draws from its own ~1 s poll, so a fresh /api/state
// fetch can be stale by tens of px) and wait for the popup. `flyingOnly` skips grounded frames —
// used right after dispatch, when the last rendered frame may predate it.
async function clickPlane(page: Page, flyingOnly: boolean): Promise<void> {
const pt = await page.evaluate(async ({ onlyFlying, starterId }: { onlyFlying: boolean; starterId: string }) => {
const m = (window as any).__flightMap;
if (!m) return null;
const deadline = Date.now() + 10_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 === starterId && (!onlyFlying || x.properties?.flying === true));
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));
}
}, { onlyFlying: flyingOnly, starterId: STARTER });
expect(pt, 'plane marker not rendered').not.toBeNull();
// Popups are created per click and never auto-close — remove any stale ones so the testids below
// stay unique.
await page.evaluate(() => document.querySelectorAll('.maplibregl-popup').forEach((el) => el.remove()));
await page.mouse.click(pt!.x, pt!.y);
const popup = page.getByTestId('plane-popup');
await expect(popup).toBeVisible({ timeout: 5_000 });
}
test('marker shows the takeoff and landing phases on a dispatched leg', async ({ page }) => {
await page.goto('/');
await waitForAirportCount(page);
// Self-heal to the onboarding state (starter grounded at HUB) regardless of what prior specs left.
await setScale(page, FAST);
await ensureGroundedAt(page, HUB);
// The exact phase boundaries for this leg — same composition the engine uses (v1.12.0).
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
const hub = fc.features.find((x) => x.properties.icao === HUB)!;
const dest = fc.features.find((x) => x.properties.icao === DEST)!;
const km = haversineKm(hub.properties.lat, hub.properties.lon, dest.properties.lat, dest.properties.lon);
const ph = legPhases(km, PLANE_TYPES[STARTER_MODEL].speedKmh, HUB, DEST);
// --- Takeoff: dispatch at OBS and read the popup while the plane holds at the origin gate. ---
await setScale(page, OBS);
const fly = await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
const T_dep = ((await fly.json()) as StateBody).planes.find((x) => x.id === STARTER)!.departureSimTime!;
await fitToAirports(page, [HUB, DEST]);
await clickPlane(page, true); // flyingOnly: the last rendered frame may predate the dispatch
await expect(page.getByTestId('plane-status')).toHaveText('flying');
await expect(page.getByTestId('plane-phase')).toContainText('taking off');
// --- Landing: fast-forward through the cruise to just before the landing window, then drop back
// to OBS so the hold at the destination gate is observable. ---
const landingStart = T_dep + ph.takeoffMs + ph.cruiseMs;
await setScale(page, MID); // fast-forward through takeoff hold + cruise (pure arrival wait)
await expect.poll(async () => {
const body = await getState(page);
const p = body.planes.find((x) => x.id === STARTER)!;
if (p.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
return body.simState.clockMs >= landingStart - 60_000; // one game-minute before the hold begins
}, { timeout: 120_000, message: 'fast-forwarding through the cruise' }).toBe(true);
await setScale(page, OBS);
// Enter the window at a point guaranteed to be inside it (map frames lag ≤ ~1 s = 30 game-s) and
// before arrival (landingMs ≥ BASE_PHASE_MS > the offset below).
const landingTarget = landingStart + Math.min(ph.landingMs / 2, 1_000);
await expect.poll(async () => {
const body = await getState(page);
if (body.planes.find((x) => x.id === STARTER)!.status !== 'flying') throw new Error('the clock jumped past the landing window — flake, retry');
return body.simState.clockMs >= landingTarget;
}, { timeout: 120_000, message: 'waiting for the landing hold to begin' }).toBe(true);
await clickPlane(page, true);
await expect(page.getByTestId('plane-status')).toHaveText('flying');
await expect(page.getByTestId('plane-phase')).toContainText('landing');
// --- Arrival: fast-forward until the engine flips it to available at DEST. ---
await setScale(page, FAST);
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.locationIcao === DEST;
}, { timeout: 60_000, message: 'waiting for the landing to complete' }).toBe(true);
// Leave the onboarding state for the other specs: starter grounded back at HUB.
await page.request.post(`/api/planes/${STARTER}/fly`, { data: { destIcao: HUB } });
await expect.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === STARTER)!;
return p.status === 'available' && p.locationIcao === HUB;
}, { timeout: 60_000, message: 'waiting for the relocation home to finish' }).toBe(true);
});