Files
Flight-Ops/e2e/hover.spec.ts
T
2026-09-20 09:16:06 +00:00

593 lines
30 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.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map.
// Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying);
// open-package rows highlight both endpoint airports. The map exposes three top-most
// highlight layers (planes-highlight / routes-highlight / airports-highlight), each filtered
// to match nothing until a hover sets the filter — asserted here via window.__flightMap's
// queryRenderedFeatures, same pattern as e2e/map.spec.ts and e2e/map-interactions.spec.ts.
// ISS-022/025/027 (v0.22.0): any hover carrying two endpoints ALSO draws the full great-circle
// trace between them in a fourth top-most layer (`hover-route`, dashed) — job-list rows, on-board
// cargo rows and the Relocate section; the relocate preview rings only its TARGET airport, which
// additionally gets a distinct green ring in `airports-target-highlight` (ISS-022).
const SCALE = 1000; // fastest sim speed (v0.14.0 ladder top) — keeps flights to seconds of real time
const HUB = 'EDDF'; // must match HUB_ICAO in lib/game/seed.ts
// Madrid–Barajas — same long leg as e2e/map-interactions.spec.ts: ~1420 km → ~10 s at scale 1000.
const DEST = 'LEMD';
interface PlaneState {
id: string;
model: string;
status: 'available' | 'flying';
fuel: number;
maxFuel: number;
locationIcao: string;
destIcao: string | null;
lat: number; // live position — interpolated per snapshot while flying, airport coords when grounded
lon: number;
}
interface PackageState {
id: string;
originName: string;
destName: string;
reward: number;
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[];
}
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;
}
// Self-heal: let any in-flight leg finish, then reposition plane-starter home via the API.
async function flyHomeViaApi(page: Page): Promise<void> {
const s0 = await getState(page);
const starter = s0.planes.find((p) => p.id === 'plane-starter')!;
if (starter.status === 'flying' || starter.locationIcao !== HUB) {
// Fast-forward any leftover leg / repositioning flight (~20 s real time worst case at 1000×).
await setScale(page, SCALE);
}
if (starter.status === 'flying') {
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);
}
if (starter.locationIcao !== HUB) {
// ISS-011: the landing above auto-slown the sim to 1× — re-set SCALE so the return leg
// fast-forwards instead of taking real-time hours.
await setScale(page, SCALE);
const res = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(res.status()).toBe(200); // repositioning needs no package (v0.12.0 manual flight)
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 240_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
}
}
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);
}
// Rendered features of the given layers in the current viewport, [] until the map exists.
function renderedFeatures(
page: Page,
layers: string[],
): Promise<Array<{ type?: unknown; id?: unknown; properties?: unknown; geometry?: unknown }>> {
return page.evaluate((ls: string[]) => {
const m = (window as any).__flightMap;
if (!m) return [];
try {
// queryRenderedFeatures returns live GeoJSONFeature instances whose `geometry` is a lazy
// PROTOTYPE getter — Playwright's evaluate serialization copies only own enumerable props,
// so geometry silently vanishes across the page boundary (CI runs #69–#71: strict-equality
// timeouts → TypeError on f.geometry.coordinates → filter-found-nothing timeouts). Map to
// plain objects IN-PAGE so the getter is evaluated before serialization.
return (m.queryRenderedFeatures(undefined, { layers: ls }) as Array<{
type?: unknown;
id?: unknown;
properties?: unknown;
geometry?: unknown;
}>).map((f) => ({
type: f.type,
id: f.id,
properties: f.properties,
geometry: f.geometry,
}));
} catch {
return [];
}
}, layers);
}
// Move the pointer to a neutral element with no hover handler — leaves any hovered row/block.
async function unhover(page: Page): Promise<void> {
await page.getByTestId('wallet-cash').hover();
}
// Fit the map so all given airports are in-viewport — queryRenderedFeatures only returns
// features rendered inside the CURRENT viewport, and the default Frankfurt-centered view leaves
// far endpoints (LEMD) off-screen even when they match a layer filter.
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);
// Give the renderer a beat after the camera jump before asserting on rendered features.
await page.waitForTimeout(300);
}
// [lon, lat] of an airport from the same /api/airports dataset the map draws its traces from.
async function airportLonLat(page: Page, icao: string): Promise<[number, number]> {
const fc = (await (await page.request.get('/api/airports')).json()) as {
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
};
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];
}
// ISS-022/025/027: the hover-route layer must hold the great-circle trace from origin to dest.
// MapLibre stores GeoJSON geometry in per-tile INTEGER grids and clips long lines across tiles, so
// queryRenderedFeatures returns ONE FEATURE PER TILE the line crosses (the 1420 km EDDF→LEMD leg is
// 4 segments at z=4 / 5 at z=5) with coordinates quantized to the rendered zoom's grid — up to ~half
// a grid step off (~0.001° observed at z≥4, ~0.02° worst case at z=0). A strict single-feature +
// exact-coords comparison can therefore never match even when the line is drawn correctly (CI run #69:
// all three hover-route timeouts while the trace was visibly rendered), so we accept any number of
// segments and require some segment to START within EPS of `from` and some to END within EPS of `to`.
// The true endpoints survive clipping verbatim in whichever tile contains them. (CI runs #69–#71:
// features could arrive WITHOUT a usable geometry because Playwright's evaluate serialization drops
// the lazy prototype getter — not MapLibre artifacts; renderedFeatures now maps to plain objects
// in-page.) The helper still filters to real LineString segments and treats "none yet" as keep-polling
// instead of throwing inside the poll callback. Call fitToAirports([from, to]) first — rendered
// features are viewport-scoped.
const HOVER_ROUTE_EPS_DEG = 0.05; // ~5 km: above worst-case quantization at any zoom, below the distance between any two EU airports
async function expectHoverRoute(page: Page, fromIcao: string, toIcao: string): Promise<void> {
const [from, to] = await Promise.all([airportLonLat(page, fromIcao), airportLonLat(page, toIcao)]);
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
// Features are plain objects with in-page-evaluated geometry (see renderedFeatures). The filter
// guards against degenerate/zero-length clipped segments and keeps this poll callback total — a
// throw inside expect.poll fails the test instantly instead of retrying. "None yet" → keep polling.
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
// The line may be clipped into several per-tile segments — check the endpoints across all of them.
const startsAtFrom = segs.some((f) => near(f.geometry.coordinates[0], from));
const endsAtTo = segs.some((f) => {
const c = f.geometry.coordinates;
return near(c[c.length - 1], to);
});
return startsAtFrom && endsAtTo;
}, { timeout: 15_000, message: `waiting for the hover-route line ${fromIcao} → ${toIcao}` }).toBe(true);
}
test('hovering a fleet row highlights the plane on the map', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF, inside the initial view
await page.goto('/');
await waitForAirportCount(page);
// Fleet rows are <li data-testid={`plane-${p.id}`}> and the server id already carries the "plane-"
// prefix (run #34: the real testid is plane-plane-starter) — derive it from /api/state, never hardcode.
const planes = (await getState(page)).planes;
const starterId = planes.find((p) => p.id.endsWith('starter'))?.id ?? (planes.length === 1 ? planes[0].id : undefined);
expect(starterId).toBeDefined();
const row = page.locator(`li[data-testid="plane-${starterId}"]`);
// The browser's /api/state polls can lag node-side requests right after a fresh page load
// (useGameState keeps loading=true until the first success → no fleet rows yet), so poll with
// diagnostics: on timeout the message compares what the NODE side sees vs what the BROWSER
// last polled, plus the fleet section's HTML — distinguishing server divergence from a render
// bug. (The value is returned, not thrown — expect.poll fails immediately on a callback throw
// instead of retrying.)
await expect
.poll(async () => {
if ((await row.count()) > 0 && (await row.isVisible())) return 'visible';
const body = await getState(page);
const browserSnap = await page.evaluate(() => (window as any).__flightLastSnapshot ?? null);
const fleetHtml = await page.evaluate(() => {
const el = document.querySelector('[data-testid="fleet-list"]');
if (el) return `fleet-list HTML: ${(el.outerHTML ?? '').slice(0, 300)}`;
const pane = document.querySelector<HTMLElement>('[data-testid="left-pane"]');
return `no fleet-list — left-pane text: ${(pane?.innerText ?? '').slice(0, 200)}`;
});
return (
`not visible — node /api/state planes=[${body.planes.map((p) => p.id).join(', ')}] cash=${body.account.cash}; ` +
`browser __flightLastSnapshot=${JSON.stringify(browserSnap)}; ${fleetHtml}`
);
}, { timeout: 30_000 })
.toBe('visible');
// No highlight before hovering (the layers start filtered to match nothing).
expect(await renderedFeatures(page, ['planes-highlight'])).toHaveLength(0);
await row.hover();
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['planes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return feats.length > 0 && feats[0].properties.id === starterId;
}, { timeout: 15_000, message: `waiting for the planes-highlight layer to show ${starterId}` }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
});
test('hovering an open package highlights its endpoint airports', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page);
const pkg = await createPackage(page, HUB, DEST);
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see them (LEMD is off-screen
// at the default Frankfurt-centered view).
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0);
await row.hover();
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-highlight layer to show both endpoints' })
.toBe(JSON.stringify([HUB, DEST].sort()));
// ISS-022 (v0.22.0): the ENTIRE trace is drawn too — a great-circle line from origin to dest,
// not just the endpoint rings.
await expectHoverRoute(page, HUB, DEST);
// And the TARGET airport gets its own distinct-colored ring — only the destination, never the origin.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the destination' })
.toBe(JSON.stringify([DEST]));
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the target ring to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board cargo row draws its trace and keeps the plane halo', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
// Load a package onto the grounded starter so it shows up in the On board section.
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);
await page.goto('/');
await waitForAirportCount(page);
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
await row.hover();
// ISS-025 (v0.22.0): same full origin→dest trace as job-list rows…
await expectHoverRoute(page, HUB, DEST);
// …and the package's plane keeps its halo lit (the hover carries planeId).
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['planes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return feats.length > 0 && feats[0].properties.id === 'plane-starter';
}, { timeout: 15_000, message: 'waiting for the planes-highlight layer to show plane-starter' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['planes-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
// Leave the instance clean: abandon the loaded package so later specs start with an empty starter.
const res = await page.request.post(`/api/packages/${pkg.id}/abandon`);
expect(res.status()).toBe(200);
});
test('hovering the Relocate section previews the planned route and target airport', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits grounded at EDDF
await page.goto('/');
await waitForAirportCount(page);
const section = page.getByTestId('relocate-section');
await expect(section, 'the Relocate section is visible').toBeVisible({ timeout: 15_000 });
// LeftPane fetches /api/airports on its own (separate from the map header count) — wait for
// the destination options to populate before selecting.
await expect(page.getByTestId('relocate-dest').locator(`option[value="${DEST}"]`)).toHaveCount(1, { timeout: 15_000 });
// Pick the starter + a known destination explicitly (the effective-selection defaults already do this).
await page.getByTestId('relocate-plane').selectOption('plane-starter');
await page.getByTestId('relocate-dest').selectOption(DEST);
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
expect(await renderedFeatures(page, ['hover-route'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-highlight'])).toHaveLength(0);
expect(await renderedFeatures(page, ['airports-target-highlight'])).toHaveLength(0);
// ISS-027 (v0.22.0): hovering the panel with a plane + destination selected previews the flight —
// dashed trace from the plane's airport to the destination…
await section.hover();
await expectHoverRoute(page, HUB, DEST);
// …and rings ONLY the target airport (the origin stays unmarked).
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-highlight layer to ring only the target' })
.toBe(JSON.stringify([DEST]));
// …plus the destination's own distinct-colored ring (ISS-022) — same single icao.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['airports-target-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return JSON.stringify(feats.map((f) => f.properties.icao).sort());
}, { timeout: 15_000, message: 'waiting for the airports-target-highlight layer to ring only the target' })
.toBe(JSON.stringify([DEST]));
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
await expect.poll(async () => (await renderedFeatures(page, ['airports-target-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the target ring to clear after mouseleave',
}).toBe(0);
});
test('hovering an on-board block of a flying plane highlights its route', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
await page.goto('/');
await waitForAirportCount(page);
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
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);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// The on-board block shows the plane in flight once a snapshot with status 'flying' lands.
const block = page.getByTestId('onboard-plane-starter');
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
// Freeze mid-flight so the ~10 s leg can't land under us while we assert (same pattern as
// e2e/map-interactions.spec.ts' marker click): the route line stays drawn while paused.
await postSim(page, { paused: true });
await block.hover();
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['routes-highlight'])) as Array<{ properties: Record<string, unknown> }>;
return feats.length > 0 && feats[0].properties.planeId === 'plane-starter';
}, { timeout: 15_000, message: 'waiting for the routes-highlight layer to show the plane-starter leg' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['routes-highlight'])).length, {
timeout: 15_000,
message: 'waiting for the highlight to clear after mouseleave',
}).toBe(0);
// Resume and fast-forward to arrival, then fly home so the live instance stays in its
// onboarding state (starter grounded at EDDF) for the other specs.
await postSim(page, { timeScale: SCALE, paused: false });
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);
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
});
test('hovering an on-board package while its carrier is mid-flight traces from current position', async ({ page }) => {
test.setTimeout(600_000); // self-heal + one flight at scale 1000 + fly-home cleanup
await flyHomeViaApi(page);
// Full tank before departure — the EDDF↔LEMD round trip burns ~4970 L of the 6300 L tank.
const refuel = await page.request.post('/api/planes/plane-starter/refuel');
expect(refuel.status()).toBe(200);
await page.goto('/');
await waitForAirportCount(page);
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts), then speed up.
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);
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
expect(fly.status()).toBe(200);
await setScale(page, SCALE);
// Wait until the starter is GENUINELY mid-flight — its live position clearly away from EDDF in
// both axes (well past HOVER_ROUTE_EPS_DEG) so "the trace starts at the plane" and "it starts at
// the pickup airport" are distinguishable. The ~10 s leg at scale 1000 gives a wide window.
const hubLonLat = await airportLonLat(page, HUB);
await expect
.poll(async () => {
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter');
if (!p || p.status !== 'flying') return false;
return Math.abs(p.lon - hubLonLat[0]) > 0.2 && Math.abs(p.lat - hubLonLat[1]) > 0.2;
}, { timeout: 60_000, message: 'waiting for plane-starter to be clearly away from the hub' })
.toBe(true);
// Freeze mid-flight so the position is stable while we assert (same pause pattern as the
// flying-plane hover test above): paused, every /api/state snapshot reports the same lat/lon.
await postSim(page, { paused: true });
const row = page.locator(`[data-testid="onboard-package-${pkg.id}"]`);
await expect(row, 'the loaded package appears in the On board section').toBeVisible({ timeout: 15_000 });
// Both endpoints must be in-viewport for queryRenderedFeatures to see the trace.
await fitToAirports(page, [HUB, DEST]);
const live = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
const liveLonLat: [number, number] = [live.lon, live.lat]; // stable — the sim is paused
const destLonLat = await airportLonLat(page, DEST);
await row.hover();
// ISS-040 (v0.23.2): the trace starts at the carrier's LIVE position and ends at the destination,
// and NO segment may start near the pickup airport (the old origin-based behavior). Same per-tile
// clipping/quantization caveats as expectHoverRoute — check endpoints across all segments with EPS.
await expect.poll(async () => {
const feats = (await renderedFeatures(page, ['hover-route'])) as Array<{ geometry?: { type: string; coordinates: [number, number][] } }>;
const segs = feats.filter(
(f): f is { geometry: { type: 'LineString'; coordinates: [number, number][] } } =>
!!f.geometry && f.geometry.type === 'LineString' && Array.isArray(f.geometry.coordinates) && f.geometry.coordinates.length >= 2,
);
if (!segs.length) return false;
const near = (a: [number, number], b: [number, number]) =>
Math.abs(a[0] - b[0]) <= HOVER_ROUTE_EPS_DEG && Math.abs(a[1] - b[1]) <= HOVER_ROUTE_EPS_DEG;
const startsAtLive = segs.some((f) => near(f.geometry.coordinates[0], liveLonLat));
const endsAtDest = segs.some((f) => near(f.geometry.coordinates[f.geometry.coordinates.length - 1], destLonLat));
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
return startsAtLive && endsAtDest && noneStartsAtHub;
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the live position to the destination' }).toBe(true);
await unhover(page);
await expect.poll(async () => (await renderedFeatures(page, ['hover-route'])).length, {
timeout: 15_000,
message: 'waiting for the hover route trace to clear after mouseleave',
}).toBe(0);
// Resume and fast-forward to arrival — landing at DEST auto-delivers the package (v0.12.0), so
// flying home afterwards leaves the starter clean for the other specs.
await postSim(page, { timeScale: SCALE, paused: false });
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);
// ISS-011: the arrival above auto-slown the sim to 1×, so re-set SCALE for this leg.
await setScale(page, SCALE);
const home = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: HUB } });
expect(home.status()).toBe(200);
await expect
.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao === HUB, {
timeout: 120_000,
message: 'waiting for plane-starter to land back at the hub',
})
.toBe(true);
});