import { describe, expect, it } from 'vitest'; import type { StateSnapshot } from './game/state'; import { planesGeoJson, queuedRoutesGeoJson, routesGeoJson, hoverRouteGeoJson, hoverTraceGeoJson, greatCircleArc, ROUTE_ARC_SEGMENTS, type AirportCoord } from './mapData'; import { haversineKm } from './geo'; import { loadAirportsGeoJson } from './airports'; import { positionAt } from './sim/movement'; import { PLANE_TYPES } from './game/catalog'; const AIRPORTS = new Map([ ['EDDF', { lat: 50.03, lon: 8.56 }], ['EDDV', { lat: 52.46, lon: 9.68 }], ['EHAM', { lat: 52.31, lon: 4.76 }], ['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test // ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180). ['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170 ['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward ['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test ]); // ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360. const modDiff = (a: number, b: number): number => { const d = Math.abs(a - b) % 360; return Math.min(d, 360 - d); }; // ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that // keeps MapLibre from drawing a chord across the map. function expectContinuousLons(coords: [number, number][]): void { for (let i = 1; i < coords.length; i++) { expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180); } } function snapshot(overrides: Partial = {}): StateSnapshot { return { account: { cash: 0, reputation: 0 }, simState: { clockMs: 0 }, planes: [], packages: [], catalog: [ { model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9_300, speedKmh: 510, maxPayloadKg: 4_800 }, ], fuelPricePerL: 0.8, sellFraction: 0.5, ...overrides, }; } describe('planesGeoJson', () => { it('returns an empty collection for a null snapshot', () => { expect(planesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); it('emits one Point per plane at its polled lat/lon with popup properties', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, { id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', departureSimTime: 1000, lat: 51.2, lon: 9.1, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, // non-crossing leg — the unwrap is a no-op, positions stay as polled ); expect(fc.features).toHaveLength(2); const grounded = fc.features[0]; expect(grounded.geometry.coordinates).toEqual([8.56, 50.03]); // [lon, lat] expect(grounded.properties.flying).toBe(false); expect(grounded.properties.modelLabel).toBe('ATR 72-600'); // catalog label, not raw model id expect(grounded.properties.fuelText).toBe('6300 / 6300 L'); expect(grounded.properties.routeText).toBe(''); const flying = fc.features[1]; expect(flying.geometry.coordinates).toEqual([9.1, 51.2]); // live polled position expect(flying.properties.flying).toBe(true); expect(flying.properties.status).toBe('flying'); expect(flying.properties.fuelText).toBe('4000 / 6300 L'); expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport'); // from the plane's own dest fields (v0.12.0) }); it('falls back to the raw model id when the catalog has no entry', () => { const fc = planesGeoJson( snapshot({ catalog: [], planes: [ { id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features[0].properties.modelLabel).toBe('mystery'); }); it('leaves routeText empty for a flying plane without a destination name', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', destIcao: null, destName: null, departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, // no destIcao — the unwrap guard skips it, position stays as polled ); expect(fc.features[0].properties.routeText).toBe(''); }); }); describe('planesGeoJson — antimeridian marker unwrap (ISS-124 v0.58.0)', () => { const nzaa = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8 const nftf = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — NZAA→NFTF crosses ±180 eastward it('eastward crossing: a mid-leg wrapped lon is unwrapped against the origin airport', () => { // positionAt wraps into [−180, 180]: past ±180 the snapshot reads −179.5 for a point at +180.5. const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); const [lon] = fc.features[0].geometry.coordinates; expect(lon).toBeCloseTo(180.5, 9); // −179.5 + 360 — continuous with the origin's +174.8 expect(Math.abs(lon - nzaa.lon)).toBeLessThan(180); // within half a turn of the anchor }); it('westward crossing (reversed pair): unwrapped below −180', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: 'NZAA', destName: 'Auckland', departureSimTime: 0, lat: -29.5, lon: 179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); const [lon] = fc.features[0].geometry.coordinates; expect(lon).toBeCloseTo(-180.5, 9); // +179.5 − 360 — continuous with the origin's −175.1 }); it('marker lon equals the route line\'s own sample at every progress point (same world copy)', () => { // The line is greatCircleArc(NZAA→NFTF) unwrapped from +174.8; feed each of its samples back as a // WRAPPED snapshot position (what positionAt produces server-side) — the marker must land exactly // on the line's sample, so marker and line agree frame to frame across the crossing. const arc = greatCircleArc(nzaa, nftf); for (const [uLon, uLat] of arc) { let wLon = uLon; // wrap into positionAt's range as the server snapshot would carry it while (wLon > 180) wLon -= 360; while (wLon <= -180) wLon += 360; const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: 'NFTF', destName: 'B', departureSimTime: 0, lat: uLat, lon: wLon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features[0].geometry.coordinates[0]).toBeCloseTo(uLon, 9); } }); it('grounded planes keep their raw position (no unwrap without a leg)', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'NFTF', locationName: "Fua'amotu", destIcao: null, destName: null, departureSimTime: null, lat: nftf.lat, lon: nftf.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features[0].geometry.coordinates).toEqual([nftf.lon, nftf.lat]); // raw −175.1, untouched }); it('a flying plane with a missing endpoint keeps its wrapped lon (no line is drawn either)', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features[0].geometry.coordinates).toEqual([-179.5, -29.5]); // wrapped — mirrors routesGeoJson's skip }); it('non-crossing legs are untouched: lons stay as polled', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'F', destIcao: 'LEMD', destName: 'M', departureSimTime: 0, lat: 45.2, lon: -1.9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features[0].geometry.coordinates).toEqual([-1.9, 45.2]); }); it('emits the exact properties e2e starterMarker matches on: id + flying', () => { const fc = planesGeoJson( snapshot({ planes: [ { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 3000, maxFuel: 6300, locationIcao: 'NZAA', locationName: 'Auckland', destIcao: 'NFTF', destName: "Fua'amotu", departureSimTime: 0, lat: -29.5, lon: -179.5, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); const props = fc.features[0].properties!; expect(props.id).toBe('plane-starter'); // starterMarker's match key expect(props.flying).toBe(true); // the flag the e2e poll waits on }); }); describe('routesGeoJson (v0.12.0 — one line per flying plane\'s current leg)', () => { it('returns an empty collection for a null snapshot', () => { expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); it('draws nothing for grounded planes, whatever their packages', () => { const fc = routesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('flying plane → one line origin→dest_icao with the plane id', () => { const fc = routesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: 'p1' }); const coords = fc.features[0].geometry.coordinates; expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled great-circle arc, not a 2-point chord (v0.12.2) expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint }); it('skips a flying plane whose endpoint is missing from the airport dataset', () => { const fc = routesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('one line per flying plane — multiple planes draw multiple legs', () => { const fc = routesGeoJson( snapshot({ planes: [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 52.4, lon: 9.6, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }), AIRPORTS, ); expect(fc.features.map((f) => f.properties.planeId).sort()).toEqual(['p1', 'p2']); }); }); describe('queuedRoutesGeoJson (ISS-043 v0.26.0 — dotted queue line: dest → queued legs only; ISS-077 v0.41.0 — grounded queues too)', () => { it('grounded plane with a non-empty queue → one feature chaining locationIcao → queued legs (ISS-077)', () => { const fc = queuedRoutesGeoJson( [ // Pre-built route while grounded: the line starts at the plane's current airport, not at any dest. { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: 'p1' }); const coords = fc.features[0].geometry.coordinates; expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // one arc, exact endpoints expect(coords[0]).toEqual([8.56, 50.03]); // starts at the plane's airport (EDDF) expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // ends at the queued airport (EHAM) }); it('skips a grounded plane whose locationIcao is missing from the airport index', () => { const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'ZZZZ', locationName: '?', destIcao: null, destName: null, departureSimTime: null, lat: 0, lon: 0, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('draws nothing for a repairing plane, even with a non-empty queue', () => { const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'repairing', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: null, destName: null, departureSimTime: null, lat: 50.03, lon: 8.56, health: 0, repairUntilSimTime: 1000, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('draws nothing for a flying plane with an empty queue', () => { const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('flying plane with a 2-leg queue → one feature chaining dest → q0 → q1 with exact endpoints', () => { const fc = queuedRoutesGeoJson( [ // Active leg EDDF→EHAM stays in routesGeoJson; the queue line is ONLY EHAM → EDDV → LEMD. { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EDDV', 'LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: 'p1' }); const coords = fc.features[0].geometry.coordinates; // Two concatenated arcs with the shared joint point dropped once: n_legs × SEGMENTS + 1 points. expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); expect(coords[0]).toEqual([4.76, 52.31]); // starts at DEST (EHAM), not at the plane's origin expect(coords[coords.length - 1]).toEqual([-3.56, 40.47]); // ends at the last queued airport (LEMD) expect(coords[ROUTE_ARC_SEGMENTS]).toEqual([9.68, 52.46]); // joint point = first queued airport (EDDV), exact }); it('skips a plane whose dest or a queued ICAO is missing from the airport index', () => { const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'ZZZZ', destName: '?', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['EHAM'], ownerId: 'local', ownerName: 'kamilkrzywda' }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['ZZZZ'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(0); }); it('one feature per qualifying plane — flying and grounded queues draw; empty-queue planes do not', () => { const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', destIcao: 'EHAM', destName: 'A', departureSimTime: 0, lat: 51, lon: 9, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' }, { id: 'p2', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EHAM', locationName: 'A', destIcao: 'EDDV', destName: 'H', departureSimTime: 0, lat: 52.3, lon: 4.7, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, { id: 'p3', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDV', locationName: 'H', destIcao: null, destName: null, departureSimTime: null, lat: 52.46, lon: 9.68, health: 100, repairUntilSimTime: null, queue: ['LEMD'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features.map((f) => f.properties.planeId)).toEqual(['p1', 'p3']); // p2 has an empty queue const p3 = fc.features[1].geometry.coordinates; expect(p3[0]).toEqual([9.68, 52.46]); // grounded chain starts at the plane's airport (EDDV) expect(p3[p3.length - 1]).toEqual([-3.56, 40.47]); // …and ends at the queued airport (LEMD) }); }); describe('hoverRouteGeoJson (ISS-022/025/027 v0.22.0 — hover route trace)', () => { it('returns an empty collection for the no-hover "" sentinel', () => { expect(hoverRouteGeoJson('', '', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); it('draws one great-circle line between two known airports with exact endpoints', () => { const fc = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane const coords = fc.features[0].geometry.coordinates; expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord expect(coords[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] — exact endpoint expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint }); it('draws the same trace reversed (endpoints swapped, arc mirrored)', () => { const ab = hoverRouteGeoJson('EDDF', 'EHAM', AIRPORTS).features[0].geometry.coordinates; const ba = hoverRouteGeoJson('EHAM', 'EDDF', AIRPORTS).features[0].geometry.coordinates; expect(ba.length).toBe(ab.length); // The slerp is symmetric up to floating-point rounding (1 − t ≠ (N−t)/N bit-exactly), so // compare with a tolerance: each reversed point sits on the forward arc within ~1e-9 degrees. for (let i = 0; i < ab.length; i++) { expect(Math.abs(ba[i][0] - ab[ab.length - 1 - i][0])).toBeLessThan(1e-9); expect(Math.abs(ba[i][1] - ab[ab.length - 1 - i][1])).toBeLessThan(1e-9); } }); it('returns an empty collection when the from airport is missing from the dataset', () => { expect(hoverRouteGeoJson('ZZZZ', 'EDDV', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); it('returns an empty collection when the to airport is missing from the dataset', () => { expect(hoverRouteGeoJson('EDDF', 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); }); describe('hoverTraceGeoJson (ISS-040 v0.23.2 — on-board package trace from the carrier\'s live position)', () => { it('draws one great-circle line from the given start coord to the dest airport with exact endpoints', () => { const from = { lat: 51.2, lon: 9.1 }; // a mid-flight position — not an airport const fc = hoverTraceGeoJson(from, 'EHAM', AIRPORTS); expect(fc.features).toHaveLength(1); expect(fc.features[0].properties).toEqual({ planeId: '' }); // pure overlay — no owning plane const coords = fc.features[0].geometry.coordinates; expect(coords.length).toBe(ROUTE_ARC_SEGMENTS + 1); // sampled arc, not a 2-point chord expect(coords[0]).toEqual([9.1, 51.2]); // live start — exact endpoint expect(coords[coords.length - 1]).toEqual([4.76, 52.31]); // EHAM — exact endpoint }); it('returns an empty collection when the dest airport is missing from the dataset', () => { expect(hoverTraceGeoJson({ lat: 0, lon: 0 }, 'ZZZZ', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); // ISS-077 (v0.41.0): a flying carrier's trace starts at its landing airport — when that IS the // package's destination there is nothing to draw (the solid active-leg layer covers it). it('returns an empty collection when the start point equals the dest airport', () => { expect(hoverTraceGeoJson(AIRPORTS.get('EHAM')!, 'EHAM', AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] }); }); }); describe('greatCircleArc (v0.12.2 — curved route traces)', () => { it('samples >2 points with the endpoints exactly at the airport coords', () => { const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('EHAM')!); expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); // 25 coordinates: 23 intermediate points expect(arc[0]).toEqual([8.56, 50.03]); // EDDF [lon, lat] expect(arc[arc.length - 1]).toEqual([4.76, 52.31]); // EHAM }); it('midpoint of a long route deviates from the straight chord (it is actually curved)', () => { const from = AIRPORTS.get('EDDF')!; const to = AIRPORTS.get('LEMD')!; // ~1400 km — sagitta ≈ 40 km off the chord const arc = greatCircleArc(from, to); const mid = arc[Math.floor(arc.length / 2)]; // t = 0.5 exactly (index SEGMENTS/2) const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2]; expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeGreaterThan(5); // km off the straight line }); it('short routes still fine: exact endpoints and a nearly-straight arc', () => { const from = AIRPORTS.get('EDDF')!; const to = AIRPORTS.get('EDDV')!; // ~280 km — sagitta ≈ 1.5 km, visually near-straight const arc = greatCircleArc(from, to); expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); expect(arc[0]).toEqual([from.lon, from.lat]); expect(arc[arc.length - 1]).toEqual([to.lon, to.lat]); const mid = arc[Math.floor(arc.length / 2)]; const chordMid: [number, number] = [(from.lon + to.lon) / 2, (from.lat + to.lat) / 2]; expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord }); }); describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => { const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8 const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => { const arc = greatCircleArc(from, to); expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1); expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact const last = arc[arc.length - 1]!; expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8 expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9); expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy const dist = haversineKm(from.lat, from.lon, to.lat, to.lon); let sum = 0; for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]); expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance }); it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => { const arc = greatCircleArc(to, from); expectContinuousLons(arc); expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact const last = arc[arc.length - 1]!; expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2 expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9); expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy }); it('non-crossing routes are untouched: lons stay in [−180, 180]', () => { const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!); expectContinuousLons(arc); for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180); for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180); }); it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => { // Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8), // leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°. const fc = queuedRoutesGeoJson( [ { id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], AIRPORTS, ); expect(fc.features).toHaveLength(1); const coords = fc.features[0]!.geometry.coordinates; expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once expectContinuousLons(coords); // the whole chain — including across the NFTF joint expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw const last = coords[coords.length - 1]!; const nzch = AIRPORTS.get('NZCH')!; expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360) expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9); }); }); describe('NZAA→NFTF crossing leg — full pipeline on stored dataset coords (ISS-124)', () => { // The exact production pipeline for one flying frame: the server snapshot holds positionAt's WRAPPED // lat/lon (atan2 → [−180, 180]); planesGeoJson re-expresses lon against the departure airport // (locationIcao is always the leg origin while flying — lib/game/state.ts). This pins what lons the // e2e marker sampling can EVER see for this dataset's stored coords. it('marker lons cross above +180 eastward, continuously', async () => { const fc = await loadAirportsGeoJson(); const nzaa = fc.features.find((f) => f.properties.icao === 'NZAA')!.properties; const nftf = fc.features.find((f) => f.properties.icao === 'NFTF')!.properties; expect(nzaa.lon).toBeGreaterThan(170); // stored east-positive (Auckland +174.8, not −175.x) expect(nftf.lon).toBeLessThan(-170); const km = haversineKm(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon); const index = new Map([ ['NZAA', { lat: nzaa.lat, lon: nzaa.lon }], ['NFTF', { lat: nftf.lat, lon: nftf.lon }], ]); const lons: number[] = []; for (let i = 0; i <= 50; i++) { const pos = positionAt(nzaa.lat, nzaa.lon, nftf.lat, nftf.lon, i / 50); // wrapped — exactly what the snapshot holds const snap = snapshot({ planes: [ { id: 'plane-starter', model: 'atr72', status: 'flying', fuel: 1000, maxFuel: PLANE_TYPES.atr72.maxFuel, locationIcao: 'NZAA', locationName: 'Auckland International Airport', destIcao: 'NFTF', destName: "Fua'amotu International Airport", departureSimTime: 0, lat: pos.lat, lon: pos.lon, health: 100, repairUntilSimTime: null, queue: [], ownerId: 'local', ownerName: 'kamilkrzywda' }, ], }); lons.push(planesGeoJson(snap, index).features[0]!.geometry.coordinates[0]!); } console.log(`NZAA→NFTF (${km.toFixed(1)} km): sampled marker lons ${JSON.stringify(lons.map((x) => Number(x.toFixed(2))))}`); for (let i = 1; i < lons.length; i++) { expect(Math.abs(lons[i]! - lons[i - 1]!), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180); } expect(Math.max(...lons)).toBeGreaterThan(180); // eastward crossing → onto the + world copy expect(Math.min(...lons)).toBeGreaterThanOrEqual(nzaa.lon - 1e-9); // never wraps to the west copy }); });