feat(layers): GNSS interference layer from ADS-B NIC/NACp integrity

Bins airborne ADS-B v1/v2 aircraft that report degraded navigation
integrity (NIC < 7, NACp < 8, or readsb's GPS-loss flag) into 0.5 degree
cells over a rolling 30-minute window, coloured with the gpsjam.org
bands. Keyless: reads the capped adsb.lol point snapshot around the view
through /api/gnss-integrity, which returns integrity fields only, caches
60 s per whole-degree anchor, backs off on upstream 429 and serves the
last snapshot on failure. Adds scripts/qa-gnss.mjs.

Built by the Hex project (Crew Leo Agile dev team).

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
Leo PechnickiandClaude authored and Mazeyar Moeini Feizabadi committed 2026-10-13 17:19:23 +02:00
1 parent f71da95e91
commit eefcb338b3
26 files changed
+1389 -4

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
} from './records.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
const FILL_ALPHA = Object.freeze({ low: 0.14, medium: 0.32, high: 0.45 });
const LEVEL_LABELS = Object.freeze({
low: 'Under 2% degraded',
medium: '2–10% degraded',
high: 'Over 10% degraded',
});
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const rectangle = viewer?.camera?.computeViewRectangle?.(
viewer.scene?.globe?.ellipsoid,
);
const carto = rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/**
* Own one GNSS-interference display: rolling integrity observations around
* the views the user visits, binned into cells coloured by the share of
* aircraft reporting degraded navigation integrity.
*/
export function createGnssInterferenceLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS interference requires a snapshot source');
let _viewer = null;
let _dataSource = null;
let _request = null;
let _enabled = false;
let _cells = [];
let _signature = null;
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
const _observations = new Map();
function render() {
const signature = JSON.stringify(
_cells.map(({ id, level, aircraft, degraded }) => [
id,
level,
aircraft,
degraded,
]),
);
if (signature === _signature) return;
_signature = signature;
_dataSource.entities.removeAll();
for (const cell of _cells) {
const color = Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
);
_dataSource.entities.add(
new Cesium.Entity({
id: `gnss-interference:${cell.id}`,
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(FILL_ALPHA[cell.level]),
),
classificationType: Cesium.ClassificationType.BOTH,
},
}),
);
}
}
const layer = {
id: 'gnss-interference',
name: 'GNSS Interference',
icon: '📡',
source: 'adsb.lol',
updateInterval: 60000,
init(viewer) {
if (_viewer)
throw new Error('GNSS interference layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('gnss-interference');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
},
async update() {
if (!_enabled || !_dataSource) return false;
const anchor = viewAnchor(_viewer);
if (!anchor) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
accumulateGnssObservations(_observations, snapshot.rows, now(), {
windowMs,
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale;
_lastUpdate = now();
_lastError = null;
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_enabled = false;
_observations.clear();
_cells = [];
_signature = null;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_viewer = null;
_lastUpdate = null;
_lastError = null;
_stale = false;
},
getRowControls() {
return {
chips: [],
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb:
'Share of ADS-B aircraft reporting low navigation integrity (NIC/NACp) over the last 30 minutes, around the views you visit. An inference of jamming or spoofing, not a detection.',
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
lastUpdate: _lastUpdate,
error: _lastError,
stale: _stale,
fallback: false,
};
},
};
return layer;
}
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGnssInterferenceLayer } from './index.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{ anchor = { latitude: 50, longitude: 30 } } = {},
) {
const sources = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssInterferenceLayer({
source: { getSnapshot },
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
});
layer.init(viewer);
return {
layer,
sources,
anchors,
advance(ms) {
clock += ms;
},
};
}
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssInterferenceLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].entities.values;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-interference:280:420');
assert.ok(entities[0].rectangle);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), false);
assert.equal(sources[0].entities.values.length, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].entities.values.length, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(await layer.update(), false);
assert.equal(calls, 0);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
+171
View File
@@ -0,0 +1,171 @@
/**
* GNSS interference inferred from ADS-B navigation integrity.
*
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). Both collapse when the GNSS receiver is jammed or spoofed, so a
* cluster of aircraft reporting low values in one area is a well-known public
* proxy for interference (the method popularized by gpsjam.org and the
* readsb/tar1090 `gpsOkBefore` flag). It is an inference, not a detection:
* avionics faults and old transponders also report low values, which is why
* cells need several aircraft and the first degraded aircraft is discounted.
*/
/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao']);
/** NIC below 7 means a containment radius above 0.2 NM (RNP 0.3 and worse). */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8 means an estimated position uncertainty of 93 m or more. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Fewer aircraft than this in a cell cannot distinguish one faulty transponder from interference. */
export const GNSS_MIN_AIRCRAFT = 3;
/** Rolling window over which aircraft seen in a cell are counted. */
export const GNSS_WINDOW_MS = 30 * 60_000;
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number, nacp: number, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!finite(aircraft.version) || aircraft.version < 1) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
const { lat, lon, nic, nac_p: nacp } = aircraft;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
if (!finite(nic) || !finite(nacp)) continue;
if (finite(aircraft.seen_pos) && aircraft.seen_pos > MAX_POSITION_AGE_S)
continue;
const gpsLost = aircraft.gpsOkBefore != null;
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost || nic < GNSS_NIC_THRESHOLD || nacp < GNSS_NACP_THRESHOLD,
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window; a degraded report in the window keeps it degraded
* there, so an aircraft that loses GNSS mid-cell is not averaged away.
*
* @param {Map<string, {cell: string, degraded: boolean, seenAt: number}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number}} [options]
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{ windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
) {
for (const row of rows || []) {
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
store.set(key, {
cell,
degraded: row.degraded || (previous?.degraded ?? false),
seenAt: nowMs,
});
}
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
}
return store;
}
/**
* Interference level of a cell, using gpsjam.org's published formula and
* bands: the first degraded aircraft is discounted to limit false positives,
* then under 2% is low, 2–10% medium and over 10% high.
*/
export function gnssInterferenceLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
return {
percent,
level: percent > 10 ? 'high' : percent >= 2 ? 'medium' : 'low',
};
}
/**
* Aggregate the observation store into displayable cells.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high'}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degraded } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degraded) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level } = gnssInterferenceLevel(aircraft, degraded);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
gnssCellKey,
gnssInterferenceLevel,
normalizeGnssAircraft,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({ hex: '000001', type: 'mlat', nic: 0 }),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
});
test('interference level follows the published gpsjam formula and bands', () => {
assert.deepEqual(gnssInterferenceLevel(10, 0), { percent: 0, level: 'low' });
// One degraded aircraft is always discounted.
assert.deepEqual(gnssInterferenceLevel(10, 1), { percent: 0, level: 'low' });
assert.equal(gnssInterferenceLevel(50, 2).level, 'medium');
assert.equal(gnssInterferenceLevel(10, 2).level, 'medium');
assert.equal(gnssInterferenceLevel(10, 3).level, 'high');
assert.equal(gnssInterferenceLevel(0, 0).level, 'low');
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
+37
View File
@@ -0,0 +1,37 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
export function createAdsbGnssSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
const params = new URLSearchParams({
lat: latitude.toFixed(2),
lon: longitude.toFixed(2),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
});
if (!response.ok) throw new Error(`adsb.lol HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
return {
rows: payload.rows,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
};
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({ fetchedAt: 5, rows: [{ hex: 'abc123' }] });
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50.06&lon=19.94']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
fetchedAt: 5,
stale: false,
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, null);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});