CI / build + unit + e2e (pull_request) Failing after 7m29s
Every leg gains a 10-min takeoff phase at the origin and a 10-min landing phase at the destination, each weather-scaled up to 2x via a monotone condition rule (Clear < Cloudy < Rain < Fog). Single composition in lib/sim/movement.ts: BASE_PHASE_MS, phaseFactor, MAX_PHASE_FACTOR, legPhases; arrivalTimeFor and progressAt route through it (progress is now piecewise — hold at origin / linear cruise / hold at destination), so engine arrival gate, client position/fuel recompute and the job expiry floor all agree. Phases burn no fuel. Marker shows the current phase next to the status in the popup (mapData derives it from departureSimTime + clockMs; new data-testid=plane-phase sibling). plane-status keeps the raw status. Tests: movement.test.ts (phaseFactor bounds/monotonicity, origin-vs-dest composition, piecewise progressAt), engine.test.ts (deleted local travelMs mirror — all ~18 fixture clocks use the real composition), seed.test.ts (phase-inclusive expiry floor mirrors). New e2e/phases.spec.ts asserts 'taking off' at the origin gate and 'landing' at the destination gate on EDDF→LEMD using the same legPhases boundaries as the engine.
205 lines
10 KiB
TypeScript
205 lines
10 KiB
TypeScript
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: boolean) => {
|
||
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 === STARTER && (!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));
|
||
}
|
||
}, flyingOnly);
|
||
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 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);
|
||
});
|