diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 2469a41..b8e69ae 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -487,20 +487,32 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' await page.reload(); await waitForAirportCount(page); - // The crossing package + dispatch. + // 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 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); + // 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 @@ -510,20 +522,48 @@ test('plane marker stays continuous across the antimeridian (no 360° teleport)' // 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?.flying && haversineKm(p.lat, p.lon, marker.lat, marker.lon) < 700) lons.push(marker.lon); + 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); } - expect(lons.length).toBeGreaterThan(3); // a real flight window of FRESH frames (stale ones are gated out above), not a degenerate one + // 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)).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 + 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).