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 { // 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 { 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 { 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 { 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 { 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 { 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 }>; 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 }>; 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 }>; 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; 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 { 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> = 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(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 position 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. `lat` feeds the // freshness gate below (a stale source frame sits far from where the SERVER says the plane is). function starterMarker(page: Page): Promise<{ lat: number; 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; geometry?: { coordinates: number[] } }>) { if (f.properties?.id === 'plane-starter' && f.geometry) { return { lat: f.geometry.coordinates[1]!, 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 { 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); } } // Fresh page before the crossing leg: this test's page has been alive ~3 min (relocation chain) and // under dind load the browser's state feed can go stale — a frozen planes source would then keep an // old FLYING frame from a relocation hop, fooling the flying poll below and sampling stale lons. // Server state persists across reloads; the plane is grounded at ORIGIN here, so on this fresh page // ONLY the crossing leg can ever produce a flying frame. await page.reload(); await waitForAirportCount(page); // The crossing package + dispatch. RUN-179 DATA COLLECTION: server-side truth is logged at every // phase (unconditionally — Playwright puts test stdout in the job log for failed tests), so a failed // run shows exactly what /api/state said while the browser source was sampled. const fmtPlane = (p: PlaneState) => `status=${p.status} loc=${p.locationIcao} dest=${p.destIcao ?? '-'} lat=${p.lat.toFixed(4)} lon=${p.lon.toFixed(4)}`; 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); console.log(`[antimeridian] pkg=${pkg.id} leg ${ORIGIN}->${CROSS_DEST}; BEFORE dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); 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); console.log(`[antimeridian] AFTER dispatch: ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); // 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 SERVER state shows plane-starter flying on THIS specific leg (origin/dest pair match) AND // the planes source agrees. A marker-only poll could pass on a stale FLYING frame from an earlier hop; // sampling must start only when this leg is actually underway server-side. await expect.poll(async () => { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; if (!(p.status === 'flying' && p.locationIcao === ORIGIN && p.destIcao === CROSS_DEST)) return false; return (await starterMarker(page))?.flying ?? false; }, { timeout: 30_000, message: `server must show plane-starter flying ${ORIGIN}->${CROSS_DEST} AND the planes source must show it flying` }).toBe(true); console.log(`[antimeridian] FLYING first observed (server): ${fmtPlane((await getState(page)).planes.find((x) => x.id === 'plane-starter')!)}`); // 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. // Freshness gate: the browser's source may lag server truth by a tick or two (SSE + tile re-render), // but a FROZEN frame would otherwise pass continuity vacuously and never show the crossing — accept // only frames within ~700 km of where the SERVER says the plane is (~5 s of flight at ×1000); haversine // is ±360°-periodic in Δlon, so wrapped server lon vs unwrapped marker lon compare directly. const lons: number[] = []; const lats: number[] = []; let rawSamples = 0; const rejected: string[] = []; const acceptedDetail: string[] = []; for (;;) { const p = (await getState(page)).planes.find((x) => x.id === 'plane-starter')!; if (p.status !== 'flying') break; rawSamples++; const marker = await starterMarker(page); if (!marker || !marker.flying) { rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker=${marker ? `not-flying (${marker.lat.toFixed(4)},${marker.lon.toFixed(4)})` : 'null'}`); } else { const d = haversineKm(p.lat, p.lon, marker.lat, marker.lon); if (d < 700) { lons.push(marker.lon); lats.push(marker.lat); // Δlon ≈ 0 → source tracks the wrapped server lon (unwrap NOT applied or pre-crossing); // Δlon ≈ +360 post-crossing → unwrap IS applied to what starterMarker reads. acceptedDetail.push(`server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) source(lon=${marker.lon.toFixed(4)} lat=${marker.lat.toFixed(4)}) Δlon=${(marker.lon - p.lon).toFixed(2)}`); } else { rejected.push(`raw#${rawSamples} server(${p.lat.toFixed(4)},${p.lon.toFixed(4)}) marker(${marker.lat.toFixed(4)},${marker.lon.toFixed(4)}) dist=${d.toFixed(1)}km ≥700`); } } await page.waitForTimeout(500); } // Unconditional diagnostics (run-179): full arrays + raw/accepted/rejected counts, so the failure log // maps directly to a cause: constant ≈174.8 → frozen pre-crossing frame; values below −180 → westward // unwrap; > 180 with Δlon≈+360 → correct eastward unwrap; empty after gate → source never fresh. console.log(`[antimeridian] sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`); console.log(`[antimeridian] sampled lats (n=${lats.length}): ${JSON.stringify(lats)}`); console.log(`[antimeridian] raw samples=${rawSamples} accepted=${lons.length} rejected=${rejected.length}`); for (const r of rejected) console.log(`[antimeridian] REJECTED: ${r}`); const detail = acceptedDetail.length <= 10 ? acceptedDetail : [...acceptedDetail.slice(0, 5), '…', ...acceptedDetail.slice(-5)]; for (const a of detail) console.log(`[antimeridian] ACCEPTED: ${a}`); expect(lons.length, `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}; rejected: ${rejected.join(' | ')}`).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), 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), `sampled lons (n=${lons.length}): ${JSON.stringify(lons)}`).toBe(true); // crossed onto the east world copy (NZAA→NFTF is eastward) expect(Math.max(...lons) - Math.min(...lons), `lon span: min=${Math.min(...lons).toFixed(4)} max=${Math.max(...lons).toFixed(4)} over ${JSON.stringify(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); } } } });