mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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
631 lines
29 KiB
JavaScript
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'));
|
|
});
|
|
});
|