v0.58.0 — plane markers no longer teleport across the antimeridian (ISS-124)
CI / build + unit + e2e (pull_request) Failing after 8m36s
CI / build + unit + e2e (pull_request) Failing after 8m36s
planesGeoJson now takes the airport index and unwraps each flying plane's longitude against its departure airport (unwrapLon, shared with unwrapLons), mirroring routesGeoJson's both-endpoints guard so a marker is unwrapped iff its line is drawn. Map.tsx passes airportsIndexRef at both call sites. Unit +6 in lib/mapData.test.ts; e2e +1 in e2e/map-interactions.spec.ts (chain-relocate to NZAA, fly the NZAA→NFTF crossing, sample marker lons).
This commit is contained in:
@@ -337,3 +337,143 @@ test('plane markers move while flying and the plane popup shows status + route',
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
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);
|
||||
}
|
||||
loc = ORIGIN;
|
||||
}
|
||||
|
||||
// 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);
|
||||
expect((await page.request.post('/api/planes/plane-starter/fly', { data: { destIcao: CROSS_DEST } })).status()).toBe(200);
|
||||
await setScale(page, SCALE);
|
||||
|
||||
// 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 its 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
|
||||
|
||||
// Cleanup: chain back home so the other specs start from the canonical state. The package was delivered
|
||||
// on arrival (generous deadline).
|
||||
for (const next of await chainTo(page, CROSS_DEST, 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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user