Files
worldmonitor/tests/regional-snapshot-mobility.test.mjs
Elie Habib 6dab59faba Phase 2 PR2: Mobility v1 population (airports/airspace/reroute/NOTAM) (#2976)
* feat(intelligence): Mobility v1 population (Phase 2 PR2)

Phase 2 PR2 of the Regional Intelligence Model. Replaces the Phase 0
empty `mobility` stub with a real MobilityState built from existing
Redis inputs — no new seeders, no new API keys, no new dependencies.

## New module: scripts/regional-snapshot/mobility.mjs

Single public entry point:

  buildMobilityState(regionId, sources) -> MobilityState

Pure function. Never throws. Wrapped in a defensive try/catch that
returns the empty shape on any unexpected builder bug so snapshot
persist can never be blocked by mobility data issues.

## Data sources (all already in the repo)

- aviation:delays:faa:v1       — US airports (FAA ASWS, Vercel seeder)
- aviation:delays:intl:v3      — ~51 non-US airports across Europe/APAC/
                                 MENA/Africa/Americas (AviationStack,
                                 Railway ais-relay, 30min refresh)
- aviation:notam:closures:v2   — global ICAO NOTAM closures (FAA-side
                                 seeder hits ICAO API with monitored
                                 ICAO list spanning all regions)
- intelligence:gpsjam:v2       — global GPS jamming hexes (Wingbits,
                                 fetch-gpsjam.mjs)
- military:flights:v1          — global military ADSB tracks (OpenSky,
                                 seed-military-flights.mjs)

## Per-field output mapping

| Field                 | Source                          | Coverage      |
|-----------------------|--------------------------------|---------------|
| airspace[]            | GPS-jam hexes aggregated 1:region | All regions  |
| flight_corridors[]    | (empty in v1)                   | -             |
| airports[]            | FAA + intl delays, severity ≥ MAJOR | All regions |
| reroute_intensity     | clip(military count / 50, 0, 1) | All regions   |
| notam_closures[]      | NOTAM reasons for region airports | All regions  |

## Region classification helpers

Three pure classifiers handle the three different region conventions
the source data uses:

  airportToSnapshotRegion(alert) — splits AviationStack AIRPORT_REGION_*
    by country so americas → {north-america, latam} and apac →
    {south-asia, east-asia}

  gpsjamRegionToSnapshotRegion(label) — maps the 17 fetch-gpsjam.mjs
    classifyRegion labels (iran-iraq, levant, ukraine-russia, etc.) to
    the 7 snapshot regions. Turkey/Caucasus intentionally lands in MENA
    per the geography.js WB override.

  latLonToSnapshotRegion(lat, lon) — bbox classifier for military
    flights. Coarse but shippable; v2 could use h3/geometry.

## Airspace aggregation (P2 design decision)

v1 emits ONE AirspaceStatus per region instead of one per GPS-jam hex.
Emitting per-hex would flood the UI (current MENA data has 40+ hexes).
The aggregated entry's `reason` lists sub-region names and high/med/low
counts so the analyst can still drill into the underlying data via the
evidence chain.

## Reroute intensity caveat

Military flight count is a CRUDE proxy for civil rerouting pressure.
A dedicated civil ADSB track-diversion pipeline would be more rigorous,
but this gets us a defensible 0-1 scalar today. Documented inline.

## Freshness registry

Added 5 new input keys to FRESHNESS_REGISTRY so the snapshot's
missing_inputs / stale_inputs classification tracks mobility sources.
Per-key maxAgeMin set to 2x the source's cron cadence:

  aviation:delays:faa:v1        — 60  min (30min cron)
  aviation:delays:intl:v3       — 90  min (30min cron + Railway jitter)
  aviation:notam:closures:v2    — 120 min (60min cron)
  intelligence:gpsjam:v2        — 240 min (120min cron)
  military:flights:v1           — 30  min (10min cron)

## seed-regional-snapshots.mjs Step 8

Replaced the hardcoded empty stub with a single call:

  const mobility = buildMobilityState(regionId, sources);

No reordering of the compute flow. Pure data, no async.

## Tests — 35 new unit tests

tests/regional-snapshot-mobility.test.mjs:

  airportToSnapshotRegion (5):
    - US/CA/MX → north-america
    - Other Americas → latam
    - IN/PK/BD → south-asia, rest of APAC → east-asia
    - europe/mena/africa direct
    - lowercase labels accepted
    - null/unknown safety

  gpsjamRegionToSnapshotRegion (4): 17 labels + "other" + undefined

  latLonToSnapshotRegion (3): major cities + oceans + invalid inputs

  buildAirports (5): empty sources, region filter, severity filter,
    severity → status (disrupted vs closed), reason passthrough

  buildAirspace (4): empty, region-scoped aggregation, no-match,
    low-level still restricted

  buildRerouteIntensity (4): zero, region-scoped count, saturation,
    missing lat/lon

  buildNotamClosures (4): empty, region attribution via airport alerts,
    unattributable ICAOs skipped, long-reason truncation

  buildMobilityState (4): full shape, all-empty sources, malformed
    inputs don't throw, region isolation across fields

## Verification

- node --test tests/regional-snapshot-mobility.test.mjs: 35/35 pass
- npm run test:data: 4537/4537 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- scripts/jsconfig.json typecheck: clean on touched files
- biome lint: clean on touched files

## Deferred to future iterations

- Direct civil flight track diversion counts per corridor
- flight_corridors[] stress level + rerouted_flights_24h
- LLM-assisted NOTAM structured parse
- h3 geometry for smarter hex-to-region attribution
- Mobility-based alert triggers in the diff engine

* fix(intelligence): address 2 review findings on #2976

P2 #1 — Mexico bbox mismatch between airport and flight classifiers

airportToSnapshotRegion() routes Mexico to north-america by country,
but latLonToSnapshotRegion()'s NA bbox started at lat 20°N. Mexico City
(19.43°N), Guadalajara (20.67°N), and most Mexican airspace fell into
latam, so NA's reroute_intensity understated its actual military
pressure and the two classifiers disagreed on the same country.

Fix: lower the NA bbox southern edge to lat 16.0°N. That captures
every major Mexican city and state capital (Tuxtla Gutiérrez at
16.75°N is the southernmost) while still routing Guatemala City
(14.6°N), San Salvador, Belize, and Honduras to latam. Tiny airports
at lat ~14.9°N (Tapachula in southern Chiapas) route to latam —
acceptable because they're not in the monitored set and don't carry
meaningful military traffic.

Added 8 regression tests asserting classifier parity across major
Mexican cities and correct routing of Central American capitals.

P2 #2 — seed-meta freshness path for undated payloads

classifyInputs() was treating undated payloads as "fresh" via its
fallback branch, so FAA/NOTAM/AviationStack/GPS-jam stalls would
never drag down snapshot_confidence. None of those four payloads
carry a top-level fetchedAt field; the seeders track freshness in
companion seed-meta:* keys only.

Fix: added an optional `metaKey` field to FreshnessRegistry entries.
classifyInputs() now prefers the metaKey companion's fetchedAt when
the spec declares one, falling back to the primary payload's
top-level timestamp (existing behavior) and finally to "fresh"
(existing fallback for legacy undated payloads).

Wired through the full pipeline:
  - freshness.mjs: added metaKey type, ALL_META_KEYS export, and
    metaPayloads parameter to classifyInputs()
  - snapshot-meta.mjs: buildPreMeta() forwards metaSources
  - seed-regional-snapshots.mjs: readAllInputs() now fetches both
    ALL_INPUT_KEYS and ALL_META_KEYS in a single pipeline and
    returns { sources, metaSources }; main() destructures and
    passes metaSources through computeSnapshot → buildPreMeta
  - computeSnapshot() signature gained an optional metaSources arg
    (defaults to {} for back-compat with existing tests)

Registered metaKey for each mobility input:
  aviation:delays:faa:v1       → seed-meta:aviation:faa
  aviation:delays:intl:v3      → seed-meta:aviation:intl
  aviation:notam:closures:v2   → seed-meta:aviation:notam
  intelligence:gpsjam:v2       → seed-meta:intelligence:gpsjam

military:flights:v1 does NOT declare a metaKey because its payload
already carries top-level fetchedAt — this is asserted in a test.

Added 11 regression tests covering:
  - every undated mobility input has a metaKey
  - military:flights:v1 does NOT need a metaKey
  - ALL_META_KEYS aggregation
  - undated payload + fresh meta → fresh
  - undated payload + STALE meta → stale (the core bug fix)
  - undated payload + missing meta → falls back to fresh
  - missing payload → missing regardless of meta
  - legacy payloads with top-level timestamp still work
  - meta without fetchedAt falls through to payload timestamp

## Verification

- node --test tests/regional-snapshot-mobility.test.mjs: 54/54 pass
  (35 original + 19 new regression tests)
- npm run test:data: 4556/4556 pass
- npm run typecheck: clean
- biome lint on touched files: clean
2026-04-12 01:03:28 +04:00

631 lines
29 KiB
JavaScript

// Tests for the Regional Intelligence Mobility v1 adapter (Phase 2 PR2).
// Pure-function unit tests; no Redis dependency. Run via:
// npm run test:data
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
airportToSnapshotRegion,
gpsjamRegionToSnapshotRegion,
latLonToSnapshotRegion,
buildAirports,
buildAirspace,
buildRerouteIntensity,
buildNotamClosures,
buildMobilityState,
} from '../scripts/regional-snapshot/mobility.mjs';
import {
classifyInputs,
FRESHNESS_REGISTRY,
ALL_META_KEYS,
} from '../scripts/regional-snapshot/freshness.mjs';
// ────────────────────────────────────────────────────────────────────────────
// airportToSnapshotRegion
// ────────────────────────────────────────────────────────────────────────────
describe('airportToSnapshotRegion', () => {
it('routes US/Canada/Mexico airports to north-america', () => {
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'USA' }), 'north-america');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Canada' }), 'north-america');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Mexico' }), 'north-america');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'United States' }), 'north-america');
});
it('routes Latin American airports to latam', () => {
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Brazil' }), 'latam');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Argentina' }), 'latam');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Colombia' }), 'latam');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Chile' }), 'latam');
});
it('splits APAC by country between south-asia and east-asia', () => {
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'India' }), 'south-asia');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'Pakistan' }), 'south-asia');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'Bangladesh' }), 'south-asia');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'Japan' }), 'east-asia');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'China' }), 'east-asia');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_APAC', country: 'Singapore' }), 'east-asia');
});
it('routes europe/mena/africa directly', () => {
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_EUROPE', country: 'Germany' }), 'europe');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_MENA', country: 'UAE' }), 'mena');
assert.equal(airportToSnapshotRegion({ region: 'AIRPORT_REGION_AFRICA', country: 'Kenya' }), 'sub-saharan-africa');
});
it('handles lowercase region labels (seeder-internal format)', () => {
assert.equal(airportToSnapshotRegion({ region: 'americas', country: 'USA' }), 'north-america');
assert.equal(airportToSnapshotRegion({ region: 'mena', country: 'Qatar' }), 'mena');
});
it('returns null for null/unknown inputs', () => {
assert.equal(airportToSnapshotRegion(null), null);
assert.equal(airportToSnapshotRegion({}), null);
assert.equal(airportToSnapshotRegion({ region: 'UNKNOWN', country: 'X' }), null);
});
});
// ────────────────────────────────────────────────────────────────────────────
// gpsjamRegionToSnapshotRegion
// ────────────────────────────────────────────────────────────────────────────
describe('gpsjamRegionToSnapshotRegion', () => {
it('maps MENA sub-regions', () => {
assert.equal(gpsjamRegionToSnapshotRegion('iran-iraq'), 'mena');
assert.equal(gpsjamRegionToSnapshotRegion('levant'), 'mena');
assert.equal(gpsjamRegionToSnapshotRegion('israel-sinai'), 'mena');
assert.equal(gpsjamRegionToSnapshotRegion('yemen-horn'), 'mena');
assert.equal(gpsjamRegionToSnapshotRegion('turkey-caucasus'), 'mena');
});
it('maps Europe sub-regions', () => {
assert.equal(gpsjamRegionToSnapshotRegion('ukraine-russia'), 'europe');
assert.equal(gpsjamRegionToSnapshotRegion('russia-north'), 'europe');
assert.equal(gpsjamRegionToSnapshotRegion('northern-europe'), 'europe');
assert.equal(gpsjamRegionToSnapshotRegion('western-europe'), 'europe');
});
it('maps SSA sub-regions', () => {
assert.equal(gpsjamRegionToSnapshotRegion('sudan-sahel'), 'sub-saharan-africa');
assert.equal(gpsjamRegionToSnapshotRegion('east-africa'), 'sub-saharan-africa');
});
it('maps South Asia, East Asia, North America', () => {
assert.equal(gpsjamRegionToSnapshotRegion('afghanistan-pakistan'), 'south-asia');
assert.equal(gpsjamRegionToSnapshotRegion('southeast-asia'), 'east-asia');
assert.equal(gpsjamRegionToSnapshotRegion('east-asia'), 'east-asia');
assert.equal(gpsjamRegionToSnapshotRegion('north-america'), 'north-america');
});
it('returns null for "other" and unknown labels', () => {
assert.equal(gpsjamRegionToSnapshotRegion('other'), null);
assert.equal(gpsjamRegionToSnapshotRegion('antarctica'), null);
assert.equal(gpsjamRegionToSnapshotRegion(undefined), null);
});
});
// ────────────────────────────────────────────────────────────────────────────
// latLonToSnapshotRegion
// ────────────────────────────────────────────────────────────────────────────
describe('latLonToSnapshotRegion', () => {
it('classifies major cities to the right region', () => {
assert.equal(latLonToSnapshotRegion(25.2532, 55.3657), 'mena'); // Dubai
assert.equal(latLonToSnapshotRegion(32.0055, 34.8854), 'mena'); // Tel Aviv
assert.equal(latLonToSnapshotRegion(51.4700, -0.4543), 'europe'); // LHR
assert.equal(latLonToSnapshotRegion(55.9736, 37.4125), 'europe'); // Moscow SVO
assert.equal(latLonToSnapshotRegion(35.5494, 139.7798), 'east-asia'); // Tokyo Haneda
assert.equal(latLonToSnapshotRegion(28.5562, 77.1000), 'south-asia'); // Delhi
assert.equal(latLonToSnapshotRegion(40.6413, -73.7781), 'north-america'); // JFK
assert.equal(latLonToSnapshotRegion(-23.4356, -46.4731), 'latam'); // São Paulo
assert.equal(latLonToSnapshotRegion(-1.3192, 36.9278), 'sub-saharan-africa'); // Nairobi
});
it('returns null for oceans and unmapped areas', () => {
assert.equal(latLonToSnapshotRegion(-70, 0), null); // Antarctica
assert.equal(latLonToSnapshotRegion(0, -150), null); // mid-Pacific
});
it('returns null for invalid inputs', () => {
assert.equal(latLonToSnapshotRegion(null, null), null);
assert.equal(latLonToSnapshotRegion(undefined, 0), null);
assert.equal(latLonToSnapshotRegion(NaN, NaN), null);
});
});
// ────────────────────────────────────────────────────────────────────────────
// buildAirports
// ────────────────────────────────────────────────────────────────────────────
function alert(overrides = {}) {
const iata = overrides.iata ?? 'ABC';
return {
id: `x-${iata}`,
iata,
icao: overrides.icao ?? `K${iata}`,
name: overrides.name ?? iata,
city: overrides.city ?? iata,
country: overrides.country ?? 'USA',
region: overrides.region ?? 'AIRPORT_REGION_AMERICAS',
delayType: overrides.delayType ?? 'FLIGHT_DELAY_TYPE_GROUND_DELAY',
severity: overrides.severity ?? 'FLIGHT_DELAY_SEVERITY_MAJOR',
avgDelayMinutes: overrides.avgDelayMinutes ?? 60,
reason: overrides.reason ?? 'Weather',
source: overrides.source ?? 'FLIGHT_DELAY_SOURCE_FAA',
};
}
describe('buildAirports', () => {
it('returns empty array when sources are missing', () => {
assert.deepEqual(buildAirports('mena', {}), []);
assert.deepEqual(buildAirports('mena', { 'aviation:delays:faa:v1': null }), []);
});
it('filters to airports in the requested region only', () => {
const sources = {
'aviation:delays:faa:v1': {
alerts: [
alert({ iata: 'JFK', icao: 'KJFK', country: 'USA' }),
alert({ iata: 'LAX', icao: 'KLAX', country: 'USA' }),
],
},
'aviation:delays:intl:v3': {
alerts: [
alert({ iata: 'DXB', icao: 'OMDB', country: 'UAE', region: 'AIRPORT_REGION_MENA' }),
alert({ iata: 'LHR', icao: 'EGLL', country: 'UK', region: 'AIRPORT_REGION_EUROPE' }),
],
},
};
const na = buildAirports('north-america', sources);
assert.equal(na.length, 2);
assert.deepEqual(na.map((a) => a.icao).sort(), ['KJFK', 'KLAX']);
const mena = buildAirports('mena', sources);
assert.equal(mena.length, 1);
assert.equal(mena[0].icao, 'OMDB');
});
it('filters out alerts below MAJOR severity', () => {
const sources = {
'aviation:delays:faa:v1': {
alerts: [
alert({ iata: 'JFK', severity: 'FLIGHT_DELAY_SEVERITY_MINOR' }),
alert({ iata: 'LAX', severity: 'FLIGHT_DELAY_SEVERITY_MODERATE' }),
alert({ iata: 'ORD', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR' }),
alert({ iata: 'ATL', severity: 'FLIGHT_DELAY_SEVERITY_SEVERE' }),
],
},
};
const na = buildAirports('north-america', sources);
assert.equal(na.length, 2);
assert.deepEqual(na.map((a) => a.name).sort(), ['ATL', 'ORD']);
});
it('maps severity to disrupted vs closed', () => {
const sources = {
'aviation:delays:faa:v1': {
alerts: [
alert({ iata: 'MAJOR', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR' }),
alert({ iata: 'SEVERE', severity: 'FLIGHT_DELAY_SEVERITY_SEVERE' }),
],
},
};
const na = buildAirports('north-america', sources);
const byName = Object.fromEntries(na.map((a) => [a.name, a.status]));
assert.equal(byName['MAJOR'], 'disrupted');
assert.equal(byName['SEVERE'], 'closed');
});
it('carries disruption_reason from the alert', () => {
const sources = {
'aviation:delays:faa:v1': {
alerts: [alert({ reason: 'Ground stop due to weather' })],
},
};
const na = buildAirports('north-america', sources);
assert.equal(na[0].disruption_reason, 'Ground stop due to weather');
});
});
// ────────────────────────────────────────────────────────────────────────────
// buildAirspace
// ────────────────────────────────────────────────────────────────────────────
describe('buildAirspace', () => {
it('returns empty when no hexes are present', () => {
assert.deepEqual(buildAirspace('mena', {}), []);
assert.deepEqual(buildAirspace('mena', { 'intelligence:gpsjam:v2': { hexes: [] } }), []);
});
it('aggregates hexes for this region into ONE AirspaceStatus entry', () => {
const sources = {
'intelligence:gpsjam:v2': {
hexes: [
{ h3: 'h1', lat: 30, lon: 45, level: 'high', region: 'iran-iraq' },
{ h3: 'h2', lat: 32, lon: 48, level: 'high', region: 'iran-iraq' },
{ h3: 'h3', lat: 31, lon: 36, level: 'medium', region: 'levant' },
// Non-region hex — must be excluded
{ h3: 'h4', lat: 50, lon: 10, level: 'high', region: 'western-europe' },
],
},
};
const out = buildAirspace('mena', sources);
assert.equal(out.length, 1);
assert.equal(out[0].airspace_id, 'gpsjam:mena');
assert.equal(out[0].status, 'restricted');
assert.match(out[0].reason, /iran-iraq.*levant|levant.*iran-iraq/);
assert.match(out[0].reason, /2 high/);
assert.match(out[0].reason, /1 medium/);
});
it('returns empty when region has no matching hexes', () => {
const sources = {
'intelligence:gpsjam:v2': {
hexes: [
{ h3: 'h1', lat: 30, lon: 45, level: 'high', region: 'iran-iraq' },
],
},
};
assert.deepEqual(buildAirspace('latam', sources), []);
});
it('handles low-only hexes as restricted (GPS jam affects RNAV)', () => {
const sources = {
'intelligence:gpsjam:v2': {
hexes: [
{ h3: 'h1', lat: 30, lon: 45, level: 'low', region: 'iran-iraq' },
],
},
};
const out = buildAirspace('mena', sources);
assert.equal(out.length, 1);
assert.equal(out[0].status, 'restricted');
});
});
// ────────────────────────────────────────────────────────────────────────────
// buildRerouteIntensity
// ────────────────────────────────────────────────────────────────────────────
describe('buildRerouteIntensity', () => {
it('returns 0 when no military flights', () => {
assert.equal(buildRerouteIntensity('mena', {}), 0);
assert.equal(buildRerouteIntensity('mena', { 'military:flights:v1': { flights: [] } }), 0);
});
it('counts only flights whose lat/lon maps to the requested region', () => {
const sources = {
'military:flights:v1': {
flights: [
{ lat: 32, lon: 35, operator: 'iaf' }, // MENA
{ lat: 31, lon: 34, operator: 'iaf' }, // MENA
{ lat: 35, lon: 139, operator: 'jsdf' }, // East Asia
{ lat: 52, lon: 13, operator: 'gaf' }, // Europe
],
},
};
const mena = buildRerouteIntensity('mena', sources);
assert.ok(mena > 0 && mena < 1);
// 2 flights / 50 = 0.04
assert.equal(Math.round(mena * 1000) / 1000, 0.04);
});
it('saturates at 1.0 for large flight counts', () => {
const flights = Array.from({ length: 100 }, () => ({ lat: 32, lon: 35 }));
const sources = { 'military:flights:v1': { flights } };
assert.equal(buildRerouteIntensity('mena', sources), 1);
});
it('ignores flights with missing lat/lon', () => {
const sources = {
'military:flights:v1': {
flights: [
{ operator: 'x' }, // no coords
{ lat: null, lon: 35 },
{ lat: 32, lon: 35 },
],
},
};
// Only the last flight counts (1/50 = 0.02)
assert.equal(buildRerouteIntensity('mena', sources), 0.02);
});
});
// ────────────────────────────────────────────────────────────────────────────
// buildNotamClosures
// ────────────────────────────────────────────────────────────────────────────
describe('buildNotamClosures', () => {
it('returns empty when no NOTAM source present', () => {
assert.deepEqual(buildNotamClosures('mena', {}), []);
assert.deepEqual(buildNotamClosures('mena', { 'aviation:notam:closures:v2': {} }), []);
});
it('emits reason strings for airports in the region that have NOTAMs', () => {
const sources = {
'aviation:notam:closures:v2': {
closedIcaos: ['OMDB', 'EGLL'],
restrictedIcaos: [],
reasons: {
OMDB: 'Runway closure until 06:00 UTC',
EGLL: 'Fuel contamination alert',
},
},
'aviation:delays:intl:v3': {
alerts: [
{ iata: 'DXB', icao: 'OMDB', country: 'UAE', region: 'AIRPORT_REGION_MENA', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR', reason: 'x' },
{ iata: 'LHR', icao: 'EGLL', country: 'UK', region: 'AIRPORT_REGION_EUROPE', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR', reason: 'x' },
],
},
};
const mena = buildNotamClosures('mena', sources);
assert.equal(mena.length, 1);
assert.match(mena[0], /OMDB.*Runway closure/);
const europe = buildNotamClosures('europe', sources);
assert.equal(europe.length, 1);
assert.match(europe[0], /EGLL.*Fuel contamination/);
});
it('skips NOTAMs whose ICAO can\'t be attributed to a region', () => {
const sources = {
'aviation:notam:closures:v2': {
closedIcaos: ['ZZZZ'],
reasons: { ZZZZ: 'Unknown' },
},
// No airport alert maps ZZZZ to a region
};
assert.deepEqual(buildNotamClosures('mena', sources), []);
});
it('truncates very long reason strings to 200 chars', () => {
const longReason = 'x'.repeat(500);
const sources = {
'aviation:notam:closures:v2': {
closedIcaos: ['OMDB'],
reasons: { OMDB: longReason },
},
'aviation:delays:intl:v3': {
alerts: [
{ iata: 'DXB', icao: 'OMDB', country: 'UAE', region: 'AIRPORT_REGION_MENA', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR', reason: 'x' },
],
},
};
const out = buildNotamClosures('mena', sources);
// "OMDB: " prefix + 200 truncated chars
assert.ok(out[0].length <= 'OMDB: '.length + 200);
});
});
// ────────────────────────────────────────────────────────────────────────────
// buildMobilityState (top-level composer)
// ────────────────────────────────────────────────────────────────────────────
describe('buildMobilityState', () => {
it('returns a fully-populated shape matching the proto', () => {
const sources = {
'aviation:delays:faa:v1': { alerts: [alert({ iata: 'JFK', severity: 'FLIGHT_DELAY_SEVERITY_MAJOR' })] },
'aviation:delays:intl:v3': { alerts: [] },
'aviation:notam:closures:v2': { closedIcaos: [], reasons: {} },
'intelligence:gpsjam:v2': { hexes: [{ h3: 'h1', lat: 40, lon: -74, level: 'high', region: 'north-america' }] },
'military:flights:v1': { flights: [{ lat: 40, lon: -74 }] },
};
const state = buildMobilityState('north-america', sources);
assert.ok(Array.isArray(state.airspace));
assert.equal(state.airspace.length, 1);
assert.ok(Array.isArray(state.flight_corridors));
assert.equal(state.flight_corridors.length, 0);
assert.ok(Array.isArray(state.airports));
assert.equal(state.airports.length, 1);
assert.ok(typeof state.reroute_intensity === 'number');
assert.ok(state.reroute_intensity >= 0 && state.reroute_intensity <= 1);
assert.ok(Array.isArray(state.notam_closures));
});
it('returns empty shape when all sources are missing', () => {
const state = buildMobilityState('mena', {});
assert.deepEqual(state, {
airspace: [],
flight_corridors: [],
airports: [],
reroute_intensity: 0,
notam_closures: [],
});
});
it('never throws on malformed source objects', () => {
const garbage = {
'aviation:delays:faa:v1': 'not an object',
'aviation:delays:intl:v3': 42,
'aviation:notam:closures:v2': null,
'intelligence:gpsjam:v2': { hexes: 'also not an array' },
'military:flights:v1': { flights: null },
};
assert.doesNotThrow(() => buildMobilityState('mena', garbage));
const state = buildMobilityState('mena', garbage);
assert.deepEqual(state, {
airspace: [],
flight_corridors: [],
airports: [],
reroute_intensity: 0,
notam_closures: [],
});
});
it('isolates regions — data for one region does not leak into another', () => {
const sources = {
'aviation:delays:faa:v1': {
alerts: [alert({ iata: 'JFK', icao: 'KJFK', country: 'USA' })],
},
'intelligence:gpsjam:v2': {
hexes: [{ h3: 'h1', lat: 32, lon: 35, level: 'high', region: 'iran-iraq' }],
},
'military:flights:v1': {
flights: [{ lat: 40, lon: -74 }, { lat: 32, lon: 35 }],
},
};
const na = buildMobilityState('north-america', sources);
const mena = buildMobilityState('mena', sources);
// NA gets its airport and its military flight, MENA doesn't
assert.equal(na.airports.length, 1);
assert.equal(mena.airports.length, 0);
// MENA gets its airspace and its military flight, NA doesn't get MENA airspace
assert.equal(mena.airspace.length, 1);
assert.equal(na.airspace.length, 0);
assert.ok(na.reroute_intensity > 0);
assert.ok(mena.reroute_intensity > 0);
});
});
// ────────────────────────────────────────────────────────────────────────────
// PR #2976 review-fix regression: Mexico lat/lon consistency (P2 #1)
// ────────────────────────────────────────────────────────────────────────────
//
// Before the fix, latLonToSnapshotRegion()'s NA bbox started at lat 20, so
// Mexican flights (Mexico City 19.4°N, Guadalajara 20.5°N, Chiapas ~16°N)
// fell into latam. Meanwhile airportToSnapshotRegion routed MX airports to
// NA by country, so NA's reroute_intensity understated actual military
// pressure and the two classifiers disagreed on the same country.
describe('Mexico classifier parity (PR #2976 P2 #1)', () => {
// Every major Mexican city sits at lat ≥ 16°N (Tuxtla Gutiérrez, the
// southernmost state capital, is at 16.75°N). A handful of tiny
// southern-Chiapas airports at lat ≈14.9°N route to latam under this
// classifier — acceptable limitation; those airports aren't in the
// monitored set and don't carry meaningful military traffic.
const mexicanCities = [
{ name: 'Mexico City', lat: 19.43, lon: -99.13 },
{ name: 'Guadalajara', lat: 20.67, lon: -103.35 },
{ name: 'Monterrey', lat: 25.68, lon: -100.32 },
{ name: 'Tijuana', lat: 32.52, lon: -117.03 },
{ name: 'Cancun', lat: 21.16, lon: -86.85 },
{ name: 'Tuxtla Gutierrez', lat: 16.75, lon: -93.11 },
];
for (const city of mexicanCities) {
it(`routes ${city.name} to north-america`, () => {
assert.equal(latLonToSnapshotRegion(city.lat, city.lon), 'north-america');
});
}
it('airport-by-country AND lat/lon classifier agree for every major Mexican city', () => {
for (const city of mexicanCities) {
const byCountry = airportToSnapshotRegion({ region: 'AIRPORT_REGION_AMERICAS', country: 'Mexico' });
const byLatLon = latLonToSnapshotRegion(city.lat, city.lon);
assert.equal(byCountry, byLatLon, `${city.name}: country-based=${byCountry} vs lat/lon=${byLatLon}`);
}
});
it('Guatemala / Belize / El Salvador go to latam (south of the 16°N break)', () => {
assert.equal(latLonToSnapshotRegion(14.60, -90.52), 'latam'); // Guatemala City
assert.equal(latLonToSnapshotRegion(13.69, -89.19), 'latam'); // San Salvador
assert.equal(latLonToSnapshotRegion(15.50, -88.03), 'latam'); // northern Honduras
});
it('US and Canada still land in north-america', () => {
assert.equal(latLonToSnapshotRegion(40.64, -73.78), 'north-america'); // JFK
assert.equal(latLonToSnapshotRegion(49.19, -123.18), 'north-america'); // YVR
});
it('South American cities stay in latam', () => {
assert.equal(latLonToSnapshotRegion(-23.44, -46.47), 'latam'); // São Paulo
assert.equal(latLonToSnapshotRegion(-33.39, -70.79), 'latam'); // Santiago
});
});
// ────────────────────────────────────────────────────────────────────────────
// PR #2976 review-fix regression: seed-meta freshness path (P2 #2)
// ────────────────────────────────────────────────────────────────────────────
//
// classifyInputs() used to treat undated payloads as fresh, which meant
// stalled FAA/NOTAM/AviationStack/GPS-jam seeders would never bump
// snapshot_confidence down. The fix threads a metaKey through each
// freshness spec so the companion seed-meta:*.fetchedAt is the canonical
// staleness signal.
describe('classifyInputs metaKey fallback (PR #2976 P2 #2)', () => {
it('declares metaKey for every undated mobility input', () => {
const mobilityKeys = new Set([
'aviation:delays:faa:v1',
'aviation:delays:intl:v3',
'aviation:notam:closures:v2',
'intelligence:gpsjam:v2',
]);
for (const spec of FRESHNESS_REGISTRY) {
if (mobilityKeys.has(spec.key)) {
assert.ok(typeof spec.metaKey === 'string' && spec.metaKey.length > 0,
`${spec.key} must declare a metaKey — its payload has no top-level fetchedAt`);
}
}
});
it('military:flights:v1 does NOT need a metaKey (payload has top-level fetchedAt)', () => {
const spec = FRESHNESS_REGISTRY.find((s) => s.key === 'military:flights:v1');
assert.ok(spec);
assert.equal(spec.metaKey, undefined);
});
it('ALL_META_KEYS collects every declared metaKey', () => {
assert.ok(ALL_META_KEYS.includes('seed-meta:aviation:faa'));
assert.ok(ALL_META_KEYS.includes('seed-meta:aviation:intl'));
assert.ok(ALL_META_KEYS.includes('seed-meta:aviation:notam'));
assert.ok(ALL_META_KEYS.includes('seed-meta:intelligence:gpsjam'));
});
it('undated payload + fresh meta → classified as fresh', () => {
const freshMeta = { fetchedAt: Date.now() - 5 * 60_000 }; // 5 min old
const result = classifyInputs(
{ 'aviation:delays:faa:v1': { alerts: [] } }, // payload present, no timestamp
{ 'seed-meta:aviation:faa': freshMeta },
);
assert.ok(result.fresh.includes('aviation:delays:faa:v1'));
assert.ok(!result.stale.includes('aviation:delays:faa:v1'));
});
it('undated payload + STALE meta → classified as stale (the key bug fix)', () => {
const staleMeta = { fetchedAt: Date.now() - 6 * 60 * 60_000 }; // 6 hours old, > 60min cap
const result = classifyInputs(
{ 'aviation:delays:faa:v1': { alerts: [] } },
{ 'seed-meta:aviation:faa': staleMeta },
);
assert.ok(result.stale.includes('aviation:delays:faa:v1'));
assert.ok(!result.fresh.includes('aviation:delays:faa:v1'));
});
it('undated payload + missing meta → falls back to fresh (cannot prove staleness)', () => {
const result = classifyInputs(
{ 'aviation:delays:faa:v1': { alerts: [] } },
{}, // no meta at all
);
assert.ok(result.fresh.includes('aviation:delays:faa:v1'));
});
it('missing payload → classified as missing regardless of meta', () => {
const result = classifyInputs(
{ 'aviation:delays:faa:v1': null },
{ 'seed-meta:aviation:faa': { fetchedAt: Date.now() } },
);
assert.ok(result.missing.includes('aviation:delays:faa:v1'));
assert.ok(!result.fresh.includes('aviation:delays:faa:v1'));
assert.ok(!result.stale.includes('aviation:delays:faa:v1'));
});
it('existing registry entries without metaKey still work via top-level timestamp', () => {
// Sanity: military:flights:v1 has top-level fetchedAt and no metaKey.
const staleFlights = { flights: [], fetchedAt: Date.now() - 60 * 60_000 }; // 60 min, > 30min cap
const result = classifyInputs({ 'military:flights:v1': staleFlights }, {});
assert.ok(result.stale.includes('military:flights:v1'));
});
it('meta payload with no fetchedAt field falls through to payload timestamp', () => {
const staleFlights = { flights: [], fetchedAt: Date.now() - 60 * 60_000 };
const result = classifyInputs(
{ 'military:flights:v1': staleFlights },
{ 'seed-meta:military:flights': { recordCount: 100 } }, // meta has no fetchedAt
);
assert.ok(result.stale.includes('military:flights:v1'));
});
});