CI / build + unit + e2e (pull_request) Successful in 6m3s
- actions/hover/two-planes: FAST=10_000 for expiry fast-forwards, landing waits and fly-home cleanups; queue: + CHAIN=4_000 for auto-chain observation, refuel test at explicit 2_000 with relaxed fuel bound - map-interactions: FAST=50_000 chain hops (~3 min -> ~15 s per direction); crossing leg stays at SCALE and now sets it BEFORE dispatch (a leftover fast rate would let the dispatch tick consume most of the leg) - hover fleet-row test asserts flying from the fly endpoint's own snapshot and freezes immediately instead of speeding up then freezing mid-leg - two-planes parallel-flight: both-grounded poll after cleanup dispatches so afterEach heal no longer pays the range-edge return leg at SCALE
682 lines
36 KiB
TypeScript
682 lines
36 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
import { authenticate } from './auth';
|
||
|
||
// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test.
|
||
test.beforeEach(async ({ page }) => { await authenticate(page); });
|
||
|
||
// 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; // test rate for fast-forwarding legs — keeps flights to seconds of real time
|
||
const FAST = 10_000; // pure arrival waits (no mid-leg observation) — LEMD-length legs land in ~1 s
|
||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||
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';
|
||
// Amsterdam — a third airport, distinct from hub and destination (the queued stop in the ISS-077 test).
|
||
const QUEUE = 'EHAM';
|
||
|
||
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 }; // ISS-117: the snapshot carries only the game clock
|
||
planes: PlaneState[];
|
||
packages: PackageState[];
|
||
}
|
||
|
||
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): set the world rate 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/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);
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Self-heal (same pattern as e2e/panes.spec.ts): restore full health / clear any repair gate from an
|
||
// interrupted run, let any in-flight leg finish, then reposition plane-starter home via the API.
|
||
async function flyHomeViaApi(page: Page): Promise<void> {
|
||
// v0.39.0 (ISS-069): wear accumulates across the shared CI DB — restore full health and clear any
|
||
// active repair gate from an interrupted run before acting on the starter.
|
||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||
let s = await getState(page); // ticks — a shortened gate flips the plane back to available here
|
||
let starter = s.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') {
|
||
// While flying, locationIcao stays at the DEPARTURE airport — re-read state after landing.
|
||
await expect.poll(async () => ((await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? ''), { timeout: 240_000 }).toBe('available');
|
||
s = await getState(page);
|
||
starter = s.planes.find((p) => p.id === 'plane-starter')!;
|
||
}
|
||
if (starter.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-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 ?? ''), { timeout: 240_000 }).toBe(HUB);
|
||
}
|
||
}
|
||
|
||
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 v0.70.0 default world view ([0,0] z2) leaves
|
||
// far endpoints (e.g. NFTF) 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];
|
||
});
|
||
// ISS-120 (v0.53.0): raw min/max bounds for a pair straddling ±180 span the WRONG side of the globe
|
||
// (−175…+174 covers the Atlantic, not the Pacific) — unwrap each point relative to its predecessor so
|
||
// the bounds follow the short arc across the date line. Non-crossing pairs are unchanged.
|
||
for (let i = 1; i < points.length; i++) {
|
||
let lon = points[i]![0];
|
||
const prev = points[i - 1]![0];
|
||
while (lon - prev > 180) lon -= 360;
|
||
while (prev - lon > 180) lon += 360;
|
||
points[i]![0] = lon;
|
||
}
|
||
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)]);
|
||
// ISS-120 (v0.53.0): antimeridian-crossing traces end UNWRAPPED — the dest-side endpoint is ≡ dest
|
||
// (mod 360) but sits at e.g. +184.9 instead of −175.1, so compare longitudes on the circle. For any
|
||
// two lons within ±180 of each other this equals the plain |Δlon| — existing routes are unaffected.
|
||
const near = (a: [number, number], b: [number, number]) => {
|
||
const dLon = Math.abs(a[0] - b[0]);
|
||
return Math.min(dLon, 360 - dLon) <= 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.
|
||
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);
|
||
});
|
||
|
||
// ISS-120 (v0.53.0): Frankfurt → Fua'amotu (Tonga) — their short great circle crosses ±180 EASTWARD
|
||
// (sampled: the raw longitude sequence jumps ~360° at the crossing, same as the original NZAA→NFTF pair).
|
||
// Before the fix the arc's wrapped samples jumped +179.x → −179.x and MapLibre connected them with a
|
||
// chord across the whole map; now the trace is one continuous line. v0.65.0: origin moved from NZAA to
|
||
// EDDF — open jobs only render where a plane can take them (the pool), so the hook must create it at
|
||
// the hub; same crossing, no relocation needed.
|
||
test('hovering an antimeridian-crossing package draws one continuous trace, no chord across the map', async ({ page }) => {
|
||
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
|
||
await flyHomeViaApi(page);
|
||
|
||
const pkg = await createPackage(page, HUB, 'NFTF');
|
||
|
||
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 });
|
||
|
||
// fitToAirports unwraps crossing pairs (ISS-120) so both endpoints are in-viewport. EDDF→NFTF spans
|
||
// ~176° of longitude — fitting it needs z≈1, below the old MIN_ZOOM=3 floor; since v0.70.0 minZoom is
|
||
// natively 0 (components/Map.tsx), no setMinZoom workaround is needed anymore. Quantization stays
|
||
// ≤~0.02° even near z=0, below EPS (:214–228).
|
||
await fitToAirports(page, [HUB, 'NFTF']);
|
||
|
||
await row.hover();
|
||
await expectHoverRoute(page, HUB, 'NFTF'); // endpoint check is mod-360 aware now (ISS-120)
|
||
|
||
// The regression itself: no rendered segment may contain a ±180 longitude jump — that was the chord.
|
||
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; // none yet — keep polling
|
||
for (const s of segs) {
|
||
const c = s.geometry.coordinates;
|
||
for (let i = 1; i < c.length; i++) {
|
||
if (Math.abs(c[i]![0] - c[i - 1]![0]) >= 180) return false; // a wrapped jump — the chord bug is back
|
||
}
|
||
}
|
||
return true;
|
||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to be continuous across the antimeridian' }).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);
|
||
});
|
||
|
||
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 the fleet row of a flying plane highlights its route', async ({ page }) => {
|
||
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||
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('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// Load + fly via the API (same pattern as e2e/map-interactions.spec.ts). The endpoint's OWN
|
||
// post-action snapshot already shows the plane flying — no poll race.
|
||
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);
|
||
const flyBody = (await fly.json()) as StateBody;
|
||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||
|
||
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||
// empty the route line while we assert. At scale 1 every /api/state snapshot reports the same state
|
||
// for minutes, so no mid-flight position window is needed at all (same pattern as the package-hover
|
||
// test below).
|
||
await setScale(page, 1);
|
||
|
||
// The fleet row shows the plane in flight once a snapshot with status 'flying' lands (ISS-054).
|
||
const block = page.getByTestId('plane-plane-starter');
|
||
await expect(block).toContainText(/in flight/i, { timeout: 15_000 });
|
||
|
||
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);
|
||
|
||
// Fast-forward to arrival (pure wait — no mid-leg observation), then fly home so the live instance
|
||
// stays in its onboarding state (starter grounded at EDDF) for the other specs. Both legs are pure
|
||
// arrivals → FAST (~1 s each instead of ~10 s).
|
||
await setScale(page, FAST);
|
||
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);
|
||
|
||
// Re-set FAST for the return leg.
|
||
await setScale(page, FAST);
|
||
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 the landing airport', async ({ page }) => {
|
||
test.setTimeout(600_000); // self-heal + one flight (frozen mid-leg) + FAST fly-home cleanup
|
||
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('/');
|
||
await waitForAirportCount(page);
|
||
|
||
// ISS-077 (v0.41.0): the package is destined for the QUEUED stop (EHAM), not the active leg's
|
||
// destination — load it, queue EHAM while grounded (pre-built routes are allowed), then start the
|
||
// HUB→DEST leg via the API (same pattern as e2e/map-interactions.spec.ts). The hover must trace
|
||
// from DEST (where this flight lands) to EHAM — NOT from the plane's live mid-flight position.
|
||
const pkg = await createPackage(page, HUB, QUEUE);
|
||
const load = await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } });
|
||
expect(load.status()).toBe(200);
|
||
const queueRes = await page.request.post('/api/planes/plane-starter/queue', { data: { destIcao: QUEUE } });
|
||
expect(queueRes.status()).toBe(200);
|
||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: DEST } });
|
||
expect(fly.status()).toBe(200);
|
||
// The endpoint's OWN post-action snapshot already shows the plane flying — no poll race.
|
||
const flyBody = (await fly.json()) as StateBody;
|
||
expect(flyBody.planes.find((p) => p.id === 'plane-starter')?.status).toBe('flying');
|
||
|
||
// Freeze immediately: at a leftover scale of 1000 the ~10 s leg could otherwise land under us and
|
||
// auto-chain the queued EHAM leg, which would change (or empty) the trace while we assert. At
|
||
// scale 1 every /api/state snapshot reports the same state for minutes — and the new rule's start
|
||
// point is the LANDING airport regardless of progress, so no mid-flight position window is needed at all.
|
||
await setScale(page, 1);
|
||
|
||
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 trace endpoints must be in-viewport for queryRenderedFeatures to see them.
|
||
await fitToAirports(page, [DEST, QUEUE]);
|
||
|
||
const destLonLat = await airportLonLat(page, DEST);
|
||
const queueLonLat = await airportLonLat(page, QUEUE);
|
||
const hubLonLat = await airportLonLat(page, HUB);
|
||
|
||
await row.hover();
|
||
// ISS-077 (v0.41.0): the trace starts at the LANDING airport (DEST) and ends at the package's
|
||
// destination (the queued stop EHAM), and NO segment may start near the departure airport — that
|
||
// was the old live-position behavior right after takeoff. 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 startsAtDest = segs.some((f) => near(f.geometry.coordinates[0], destLonLat));
|
||
const endsAtQueue = segs.some((f) => {
|
||
const c = f.geometry.coordinates;
|
||
return near(c[c.length - 1], queueLonLat);
|
||
});
|
||
const noneStartsAtHub = !segs.some((f) => near(f.geometry.coordinates[0], hubLonLat));
|
||
return startsAtDest && endsAtQueue && noneStartsAtHub;
|
||
}, { timeout: 15_000, message: 'waiting for the hover-route trace to run from the landing airport (DEST) to the queued stop' }).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);
|
||
|
||
// Cleanup: cancel the queued leg first so landing at DEST is a plain grounding (no auto-chain),
|
||
// resume + fast-forward to arrival, abandon the still-loaded package (it was destined for EHAM,
|
||
// not DEST — it never delivered), then fly home.
|
||
const cancelRes = await page.request.post('/api/planes/plane-starter/queue/cancel', { data: { index: 0 } });
|
||
expect(cancelRes.status()).toBe(200);
|
||
// Pure arrival wait (the queue is empty — nothing to observe mid-leg) → FAST.
|
||
await setScale(page, FAST);
|
||
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);
|
||
|
||
const abandon = await page.request.post(`/api/packages/${pkg.id}/abandon`);
|
||
expect(abandon.status()).toBe(200); // only possible once grounded — the poll above guarantees it
|
||
|
||
// Re-set FAST for the return leg (~1 s instead of ~10 s).
|
||
await setScale(page, FAST);
|
||
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);
|
||
});
|