195 lines
8.0 KiB
TypeScript
195 lines
8.0 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { assertUniqueIcaos, filterWorldAirports, isRemote, parseCsv, toGeoJson, airportsHandler, REMOTE_RADIUS_KM } from './airports';
|
|
|
|
const HEADER =
|
|
'"id","ident","type","name","latitude_deg","longitude_deg","elevation_ft","continent","iso_country","iso_region","municipality","scheduled_service","icao_code","iata_code","gps_code","local_code","home_link","wikipedia_link","keywords"';
|
|
|
|
function row(over: Record<string, string> = {}): string {
|
|
const base: Record<string, string> = {
|
|
id: '1',
|
|
ident: 'XXXX',
|
|
type: 'small_airport',
|
|
name: 'Test Field',
|
|
latitude_deg: '50.0',
|
|
longitude_deg: '8.0',
|
|
elevation_ft: '',
|
|
continent: 'EU',
|
|
iso_country: 'DE',
|
|
iso_region: 'DE-BE',
|
|
municipality: 'Nowhere',
|
|
scheduled_service: 'no',
|
|
icao_code: 'XXXX',
|
|
iata_code: '',
|
|
gps_code: '',
|
|
local_code: '',
|
|
home_link: '',
|
|
wikipedia_link: '',
|
|
keywords: '',
|
|
};
|
|
return Object.entries({ ...base, ...over })
|
|
.map(([, v]) => `"${v.replace(/"/g, '""')}"`)
|
|
.join(',');
|
|
}
|
|
|
|
describe('parseCsv', () => {
|
|
it('parses simple rows into objects keyed by header', () => {
|
|
const text = [HEADER, row()].join('\n');
|
|
const rows = parseCsv(text);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].ident).toBe('XXXX');
|
|
expect(rows[0].continent).toBe('EU');
|
|
});
|
|
|
|
it('handles quoted fields containing commas and escaped quotes', () => {
|
|
const text = [HEADER, row({ name: 'A, B "South" Field' })].join('\n');
|
|
const rows = parseCsv(text);
|
|
expect(rows[0].name).toBe('A, B "South" Field');
|
|
});
|
|
|
|
it('handles CRLF line endings and trailing newline', () => {
|
|
const text = [HEADER, row(), row({ ident: 'YYYY' })].join('\r\n') + '\r\n';
|
|
const rows = parseCsv(text);
|
|
expect(rows.map((r) => r.ident)).toEqual(['XXXX', 'YYYY']);
|
|
});
|
|
});
|
|
|
|
describe('filterWorldAirports (v0.44.0 — world dataset, no continent check)', () => {
|
|
it('keeps fixed-wing airports and seaplane bases from every continent, drops the rest by type', () => {
|
|
const rows = parseCsv(
|
|
[
|
|
HEADER,
|
|
row({ ident: 'EU-SMALL', type: 'small_airport' }),
|
|
row({ ident: 'NA-LRG', continent: 'NA', type: 'large_airport' }),
|
|
row({ ident: 'AS-LRG', continent: 'AS', type: 'large_airport' }),
|
|
row({ ident: 'EU-SEA', type: 'seaplane_base' }),
|
|
row({ ident: 'EU-LEGACY', type: 'airport' }),
|
|
row({ ident: 'NA-Helio', continent: 'NA', type: 'heliport' }),
|
|
row({ ident: 'AS-CLOSED', continent: 'AS', type: 'closed' }),
|
|
].join('\n'),
|
|
);
|
|
const kept = filterWorldAirports(rows).map((r) => r.ident);
|
|
expect(kept.sort()).toEqual(['AS-LRG', 'EU-LEGACY', 'EU-SEA', 'EU-SMALL', 'NA-LRG']);
|
|
});
|
|
});
|
|
|
|
describe('assertUniqueIcaos (v0.44.0 load-time guard)', () => {
|
|
it('passes a collection with unique ICACOs and throws on the first duplicate', () => {
|
|
const ok = toGeoJson(parseCsv([HEADER, row({ ident: 'AAAA', icao_code: 'AAAA' }), row({ ident: 'BBBB', icao_code: 'BBBB' })].join('\n')));
|
|
expect(() => assertUniqueIcaos(ok)).not.toThrow();
|
|
|
|
// icao_code empty → the effective key is the ident fallback (toGeoJson), so a duplicate
|
|
// ident must trip the guard too.
|
|
const dup = toGeoJson(parseCsv([HEADER, row({ ident: 'CCCC', icao_code: '' }), row({ ident: 'CCCC', icao_code: '', latitude_deg: '51.0' })].join('\n')));
|
|
expect(() => assertUniqueIcaos(dup)).toThrow(/duplicate ICAO.*CCCC/);
|
|
});
|
|
});
|
|
|
|
describe('toGeoJson', () => {
|
|
it('builds a FeatureCollection with [lon, lat] coordinates and expected properties', () => {
|
|
const fc = toGeoJson(parseCsv([HEADER, row({ ident: 'EDDF', icao_code: 'EDDF', name: 'Frankfurt Main Airport' })].join('\n')));
|
|
expect(fc.type).toBe('FeatureCollection');
|
|
expect(fc.features).toHaveLength(1);
|
|
const f = fc.features[0];
|
|
expect(f.geometry).toEqual({ type: 'Point', coordinates: [8.0, 50.0] });
|
|
expect(f.properties).toMatchObject({ name: 'Frankfurt Main Airport', icao: 'EDDF', lat: 50.0, lon: 8.0 });
|
|
});
|
|
|
|
it('falls back to ident when icao_code is empty and skips rows without valid coordinates', () => {
|
|
const fc = toGeoJson(
|
|
parseCsv(
|
|
[
|
|
HEADER,
|
|
row({ ident: 'NOICAO', icao_code: '' }),
|
|
row({ ident: 'BADLAT', latitude_deg: 'not-a-number' }),
|
|
row({ ident: 'NONAME', name: '' }),
|
|
].join('\n'),
|
|
),
|
|
);
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0].properties.icao).toBe('NOICAO');
|
|
});
|
|
});
|
|
|
|
describe('/api/airports handler', () => {
|
|
it('returns a valid GeoJSON FeatureCollection of world airports', async () => {
|
|
const res = await airportsHandler();
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toContain('application/json');
|
|
const fc = (await res.json()) as { type: string; features: Array<{ properties: Record<string, unknown> }> };
|
|
expect(fc.type).toBe('FeatureCollection');
|
|
expect(fc.features.length).toBeGreaterThan(0);
|
|
for (const f of fc.features.slice(0, 50)) {
|
|
expect(f.properties.name).toBeTruthy();
|
|
expect(f.properties.icao).toBeTruthy();
|
|
expect(typeof f.properties.lat).toBe('number');
|
|
expect(typeof f.properties.lon).toBe('number');
|
|
}
|
|
});
|
|
|
|
it('caches the parsed dataset in memory', async () => {
|
|
const a = await airportsHandler();
|
|
const b = await airportsHandler();
|
|
expect(await a.json()).toEqual(await b.json());
|
|
});
|
|
});
|
|
|
|
describe('isRemote (v0.61.0 — ISS-097 special remote airports)', () => {
|
|
it('synthetic: an isolated point is remote; a neighbour inside the radius is not', () => {
|
|
// Equator pair: Δlon 8.5° ≈ 945 km (inside REMOTE_RADIUS_KM) vs Δlon 9.5° ≈ 1052 km (outside).
|
|
const near = [
|
|
{ icao: 'AAA', lat: 0, lon: 0 },
|
|
{ icao: 'BBB', lat: 0, lon: 8.5 },
|
|
];
|
|
expect(isRemote(near, 'AAA')).toBe(false);
|
|
expect(isRemote(near, 'BBB')).toBe(false);
|
|
|
|
const far = [
|
|
{ icao: 'AAA', lat: 0, lon: 0 },
|
|
{ icao: 'CCC', lat: 0, lon: 9.5 },
|
|
];
|
|
expect(isRemote(far, 'AAA')).toBe(true);
|
|
expect(isRemote(far, 'CCC')).toBe(true);
|
|
|
|
// A lone airport has no other member to be near — remote by definition.
|
|
expect(isRemote([{ icao: 'SOLO', lat: -77.84, lon: 166.91 }], 'SOLO')).toBe(true);
|
|
});
|
|
|
|
it('real coords: Frankfurt↔Amsterdam (~370 km) is not remote; McMurdo vs Christchurch (~3800 km) is', () => {
|
|
const eu = [
|
|
{ icao: 'EDDF', lat: 50.0376, lon: 8.5622 }, // Frankfurt Main
|
|
{ icao: 'EHAM', lat: 52.3093, lon: 13.3698 }, // Amsterdam Schiphol
|
|
];
|
|
expect(isRemote(eu, 'EDDF')).toBe(false);
|
|
|
|
const pacific = [
|
|
{ icao: 'MCDD', lat: -77.8425, lon: 166.9133 }, // McMurdo Station (Antarctica)
|
|
{ icao: 'NZCH', lat: -43.4893, lon: 172.5308 }, // Christchurch
|
|
];
|
|
expect(isRemote(pacific, 'MCDD')).toBe(true);
|
|
expect(REMOTE_RADIUS_KM).toBe(1000);
|
|
});
|
|
|
|
it('an unknown ICAO is not remote', () => {
|
|
expect(isRemote([{ icao: 'AAA', lat: 0, lon: 0 }], 'ZZZ')).toBe(false);
|
|
});
|
|
|
|
it('committed dataset curation rule: every non-large row has no large airport within REMOTE_RADIUS_KM', async () => {
|
|
const text = await fs.readFile(path.join(process.cwd(), 'data', 'airports_world.csv'), 'utf8');
|
|
const rows = filterWorldAirports(parseCsv(text));
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
|
|
const large = rows.filter((r) => r.type === 'large_airport').map((r) => ({ icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg }));
|
|
const nonLarge = rows.filter((r) => r.type !== 'large_airport');
|
|
// The dataset must actually carry the "special" airports — not just large ones.
|
|
expect(nonLarge.length).toBeGreaterThan(0);
|
|
|
|
for (const r of nonLarge) {
|
|
const self = { icao: r.icao_code || r.ident, lat: +r.latitude_deg, lon: +r.longitude_deg };
|
|
// Remote against the kept set: no LARGE airport within REMOTE_RADIUS_KM (the curation rule).
|
|
expect(isRemote([...large, self], self.icao), `${self.icao} (${r.name}) must be remote`).toBe(true);
|
|
}
|
|
});
|
|
});
|