530 lines
26 KiB
TypeScript
530 lines
26 KiB
TypeScript
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; // test rate for fast-forwarding legs
|
||
const WORLD = 24; // the fixed world rate (ISS-117) — must match lib/worldClock.ts
|
||
|
||
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 }; // ISS-117: the snapshot carries only the game clock
|
||
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> {
|
||
// 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);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Features in LOADED tiles only — MapLibre loads GeoJSON per viewport tile, so the camera must be over
|
||
// the area of interest first ([] until then). Same trap as run-176: Europe view + Pacific plane = [].
|
||
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 — deterministic baseline (ISS-082 hook; dispatch would auto-refuel anyway).
|
||
await setFuelFull(page, 'plane-starter');
|
||
|
||
// 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 setScale(page, 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 moving marker: at scale 1000 the rendered marker lags
|
||
// our fresh /api/state fetch by up to one map poll (~1 s). At scale 1 (and this continent-level
|
||
// view) the per-second drift is sub-pixel, so the position is static for all practical purposes;
|
||
// we restore SCALE right after the popup checks.
|
||
await setScale(page, 1);
|
||
// At scale 1 a flying plane still advances every tick (the ATR72 cruises ~0.14 km/s of real time),
|
||
// so exact lat/lon equality between polls can never hold — "settled" means slow enough to click:
|
||
// two consecutive snapshots within EPS of each other. Per-tick advance is clamped to
|
||
// MAX_TICK_ELAPSED_MS (2 s) × scale, so at scale 1 the worst-case per-poll drift is ~0.5 km even
|
||
// for the fastest catalog plane; 5 km sits far above that and far below what a still-fast clock
|
||
// would show (~km per poll pair).
|
||
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 && haversineKm(prevPos.lat, prevPos.lon, p.lat, p.lon) < 5) return true;
|
||
prevPos = { lat: p.lat, lon: p.lon };
|
||
return false;
|
||
}, { timeout: 15_000, message: 'waiting for the scale-1 clock to slow the plane to click-safe drift' })
|
||
.toBe(true);
|
||
|
||
// Click the RENDERED marker position, not a fresh /api/state fetch. With the clock at scale 1
|
||
// above the server's analytic position barely moves — 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}`);
|
||
|
||
// Restore SCALE before step 5 — the landing wait needs a fast clock.
|
||
await setScale(page, SCALE);
|
||
|
||
// 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);
|
||
});
|
||
|
||
// ISS-124 (v0.58.0): a marker on a leg crossing the antimeridian must not teleport — its longitude is
|
||
// unwrapped against the departure airport in planesGeoJson (lib/mapData.ts), so consecutive polls stay
|
||
// continuous exactly like the route line. The starter can't fly EDDF→NZAA in one leg (~19,000 km vs the
|
||
// ATR72's ~3,700 km guaranteed range), so relocation is a fewest-legs chain over /api/airports with each
|
||
// hop ≤ MAX_LEG_KM (within that range even in worst-case headwind); set-fuel tops the tank for free before
|
||
// every dispatch so auto-refuel costs nothing.
|
||
const MAX_LEG_KM = 3400;
|
||
|
||
// Fewest-legs chain from → to over /api/airports, each hop ≤ maxLegKm — BFS with adjacency sorted by
|
||
// (distance, ICAO) so the chain is deterministic for a given dataset. Excludes `from`, includes `to`.
|
||
async function chainTo(page: Page, fromIcao: string, toIcao: string): Promise<string[]> {
|
||
const fc = (await (await page.request.get('/api/airports')).json()) as {
|
||
features: Array<{ properties: { icao: string; lat: number; lon: number } }>;
|
||
};
|
||
const aps = fc.features.map((f) => f.properties);
|
||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||
const havKm = (a: { lat: number; lon: number }, b: { lat: number; lon: number }) => {
|
||
const dLat = toRad(b.lat - a.lat);
|
||
const dLon = toRad(b.lon - a.lon);
|
||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(s)));
|
||
};
|
||
const idx = new Map(aps.map((a, i) => [a.icao, i]));
|
||
const adj: Array<Array<[number, number]>> = aps.map(() => []);
|
||
for (let i = 0; i < aps.length; i++) {
|
||
for (let j = i + 1; j < aps.length; j++) {
|
||
const d = havKm(aps[i]!, aps[j]!);
|
||
if (d <= MAX_LEG_KM) {
|
||
adj[i]!.push([j, d]);
|
||
adj[j]!.push([i, d]);
|
||
}
|
||
}
|
||
}
|
||
for (const a of adj) a.sort((x, y) => x[1] - y[1] || aps[x[0]]!.icao.localeCompare(aps[y[0]]!.icao));
|
||
const s = idx.get(fromIcao)!;
|
||
const t = idx.get(toIcao)!;
|
||
const prev = new Array<number>(aps.length).fill(-1);
|
||
const queue: number[] = [s];
|
||
for (let qi = 0; qi < queue.length && prev[t] === -1; qi++) {
|
||
const u = queue[qi]!;
|
||
for (const [v] of adj[u]!) {
|
||
if (prev[v] === -1) {
|
||
prev[v] = u;
|
||
queue.push(v);
|
||
}
|
||
}
|
||
}
|
||
expect(prev[t], `no ≤${MAX_LEG_KM} km chain from ${fromIcao} to ${toIcao}`).not.toBe(-1);
|
||
const path: string[] = [];
|
||
for (let cur = t; cur !== s; cur = prev[cur]!) path.unshift(aps[cur]!.icao);
|
||
return path;
|
||
}
|
||
|
||
// plane-starter's marker from the planes source: its longitude plus whether that frame is a FLYING one.
|
||
// Extracted IN-PAGE because MapLibre feature geometry can be a lazy prototype getter that Playwright's
|
||
// evaluate serialization drops (same trap as e2e/hover.spec.ts). The `flying` flag matters for the
|
||
// sampling loop: a grounded frame snaps back to the raw airport coordinate (−175.1), which is the same
|
||
// screen point but a 360° jump in raw data — only flying frames may be recorded.
|
||
function starterMarker(page: Page): Promise<{ lon: number; flying: boolean } | null> {
|
||
return page.evaluate(() => {
|
||
const m = (window as any).__flightMap;
|
||
if (!m || !m.getSource('planes')) return null;
|
||
try {
|
||
for (const f of m.querySourceFeatures('planes') as Array<{ properties?: Record<string, unknown>; geometry?: { coordinates: number[] } }>) {
|
||
if (f.properties?.id === 'plane-starter' && f.geometry) {
|
||
return { lon: f.geometry.coordinates[0]!, flying: f.properties.flying === true };
|
||
}
|
||
}
|
||
} catch {
|
||
// map/source not ready yet — null keeps the caller polling
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
|
||
// Jump the camera to a box around the given airports. Copied from e2e/hover.spec.ts — that version
|
||
// unwraps crossing pairs (ISS-120) so a ±180-straddling pair fits the Pacific, not the Atlantic.
|
||
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];
|
||
});
|
||
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);
|
||
}
|
||
|
||
test('plane marker stays continuous across the antimeridian (no 360° teleport)', async ({ page }) => {
|
||
test.setTimeout(900_000); // relocation chain (~3 min at 1000×) + crossing leg + return chain + margin
|
||
|
||
await page.goto('/');
|
||
await waitForAirportCount(page);
|
||
|
||
const ORIGIN = 'NZAA'; // Auckland — east of +170
|
||
const CROSS_DEST = 'NFTF'; // Fua'amotu (Tonga) — west of −175; the short arc crosses ±180 EASTWARD
|
||
|
||
// Self-heal: full health, let any in-flight leg finish, then relocate to ORIGIN via the chain.
|
||
expect((await page.request.post('/api/test/set-health', { data: { planeId: 'plane-starter', health: 100, repairUntilMs: 0 } })).status()).toBe(200);
|
||
if ((await getState(page)).planes.find((p) => p.id === 'plane-starter')!.status === 'flying') {
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||
}
|
||
// Relocate + crossing leg. The finally ALWAYS runs — even when a poll below fails, the plane must end
|
||
// grounded at HUB so no later spec's fly-home gets a 409 (the run-176 pollution cascade).
|
||
try {
|
||
let loc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||
if (loc !== ORIGIN) {
|
||
for (const next of await chainTo(page, loc, ORIGIN)) {
|
||
await setFuelFull(page, 'plane-starter'); // full tank → auto-refuel tops up 0 L and costs nothing
|
||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||
}
|
||
}
|
||
|
||
// The crossing package + dispatch.
|
||
await setFuelFull(page, 'plane-starter');
|
||
const pkg = await createPackage(page, ORIGIN, CROSS_DEST);
|
||
expect((await page.request.post(`/api/packages/${pkg.id}/load`, { data: { planeId: 'plane-starter' } })).status()).toBe(200);
|
||
const fly = await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } });
|
||
if (fly.status() !== 200) throw new Error(`dispatch POST /api/planes/plane-starter/fly → ${fly.status()} (expected 200)`);
|
||
await setScale(page, SCALE);
|
||
|
||
// querySourceFeatures only sees LOADED tiles — the default view is over Europe and this leg is in the
|
||
// Pacific: fit the camera over both endpoints first or the plane feature never appears in queries.
|
||
await fitToAirports(page, [ORIGIN, CROSS_DEST]);
|
||
|
||
// Wait until the map's planes source shows it flying (the source updates from ~1 s state polls).
|
||
await expect.poll(async () => (await starterMarker(page))?.flying ?? false, { timeout: 15_000, message: 'waiting for the flying plane feature in the planes source' }).toBe(true);
|
||
|
||
// Sample the marker's longitude while it crosses. Stop once the SERVER says landed; record only
|
||
// FLYING frames — a grounded frame snaps back to the raw airport coordinate (−175.1), the same screen
|
||
// point but a 360° jump in raw data, and could land between this loop's server read and source read.
|
||
const lons: number[] = [];
|
||
for (;;) {
|
||
const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!;
|
||
if (p.status !== 'flying') break;
|
||
const marker = await starterMarker(page);
|
||
if (marker?.flying) lons.push(marker.lon);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
expect(lons.length).toBeGreaterThan(3); // a real flight window, not a degenerate one
|
||
for (let i = 1; i < lons.length; i++) {
|
||
expect(Math.abs(lons[i]! - lons[i - 1]!), `marker jumped between samples ${i - 1} and ${i}`).toBeLessThan(180); // the ISS-124 bug
|
||
}
|
||
expect(lons.some((lon) => lon > 180)).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward)
|
||
expect(Math.max(...lons) - Math.min(...lons)).toBeGreaterThan(4); // traversed the crossing region, not a static window
|
||
} finally {
|
||
// Cleanup: let any in-flight leg land, then chain back home so the other specs start from the canonical
|
||
// state. The package was delivered on arrival (generous deadline).
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.status ?? '', { timeout: 240_000 }).toBe('available');
|
||
const endLoc = (await getState(page)).planes.find((p) => p.id === 'plane-starter')!.locationIcao;
|
||
if (endLoc !== HUB) {
|
||
for (const next of await chainTo(page, endLoc, HUB)) {
|
||
await setFuelFull(page, 'plane-starter');
|
||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: next } })).status()).toBe(200);
|
||
await setScale(page, SCALE);
|
||
await expect.poll(async () => (await getState(page)).planes.find((p) => p.id === 'plane-starter')?.locationIcao ?? '', { timeout: 240_000 }).toBe(next);
|
||
}
|
||
}
|
||
}
|
||
});
|