mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(gnss): age degraded evidence and replayed snapshots on their own clocks
Maintainer review on #855 (mazy1998, 2026-10-06): - records: presence (seenAt) and degraded evidence (degradedAt) age separately, so continuing healthy reports no longer keep an older degraded flag alive past 30 minutes; an aircraft returning after the window starts fresh. pruneGnssObservations ages the store with no new rows. - Rows are dated by the snapshot's fetchedAt, which the proxy keeps on cached and stale answers: a replay never restarts the window, the proxy stops serving a stale snapshot older than the window, the source refuses an undated one, and cells expire during fetch failures and while there is no view anchor. - Normalization accepts ADS-B versions 1 and 2 only; a missing, invalid or negative seen_pos is not a confirmed position from the last minute. - qa-gnss: keyboard, 390x844 viewport (fresh page: isMobile reloads) and replay-age sections; the live cell assertion bins the returned rows with the layer's own rules instead of a raw row count.
This commit is contained in:
1 parent
c8d3f44bb7
commit
fde8b2a64f
11 files changed
+557
-65
No files matched your search
@@ -141,12 +141,35 @@ test('an upstream failure serves the last snapshot as stale, else 502', async ()
|
||||
const stale = await request();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(
|
||||
stale.body.fetchedAt,
|
||||
0,
|
||||
'a stale answer keeps the time it was observed',
|
||||
);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
const missing = await request('/?lat=-10&lon=-10');
|
||||
assert.equal(missing.status, 502);
|
||||
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('a stale snapshot older than the layer window is no longer served', async () => {
|
||||
let clock = 0;
|
||||
let fail = false;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () =>
|
||||
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
|
||||
});
|
||||
await request();
|
||||
fail = true;
|
||||
clock = 30 * 60_000;
|
||||
assert.equal((await request()).status, 200);
|
||||
clock = 30 * 60_000 + 1;
|
||||
const expired = await request();
|
||||
assert.equal(expired.status, 502);
|
||||
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
|
||||
});
|
||||
|
||||
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
|
||||
let clock = 0;
|
||||
let calls = 0;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
gnssProvenance,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
export * from './records.js';
|
||||
export { createAdsbGnssSource } from './source.js';
|
||||
@@ -62,6 +63,13 @@ export function createGnssIntegrityLayer({
|
||||
const _observations = new Map();
|
||||
const provenance = gnssProvenance({ windowMs });
|
||||
|
||||
/** Age the store without new evidence, so expired cells leave the globe. */
|
||||
function expire() {
|
||||
pruneGnssObservations(_observations, now(), { windowMs });
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Only the id and band change what is drawn; counts alone do not.
|
||||
const signature = JSON.stringify(
|
||||
@@ -129,8 +137,11 @@ export function createGnssIntegrityLayer({
|
||||
const anchor = viewAnchor(_viewer);
|
||||
// No anchor yet (camera still settling) is not a failure: returning
|
||||
// false would make the manager reject the enable. The next interval
|
||||
// retries.
|
||||
if (!anchor) return true;
|
||||
// retries; cells already drawn still age out of the window.
|
||||
if (!anchor) {
|
||||
expire();
|
||||
return true;
|
||||
}
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
@@ -140,8 +151,11 @@ export function createGnssIntegrityLayer({
|
||||
});
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
// Date the rows by when the proxy observed them: a cached or stale
|
||||
// replay keeps its original age instead of restarting the window.
|
||||
accumulateGnssObservations(_observations, snapshot.rows, now(), {
|
||||
windowMs,
|
||||
observedAt: snapshot.fetchedAt,
|
||||
});
|
||||
_cells = binGnssCells(_observations);
|
||||
render();
|
||||
@@ -154,6 +168,8 @@ export function createGnssIntegrityLayer({
|
||||
return false;
|
||||
console.warn('[Data:GNSS] Fetch error:', e);
|
||||
_lastError = e?.message || 'GNSS integrity source unavailable';
|
||||
// Keep the last cells only while their evidence is inside the window.
|
||||
expire();
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
|
||||
@@ -14,8 +14,9 @@ const row = (hex, lat, degraded) => ({
|
||||
|
||||
function harness(
|
||||
getSnapshot,
|
||||
{ anchor = { latitude: 50, longitude: 30 } } = {},
|
||||
{ anchor: initialAnchor = { latitude: 50, longitude: 30 } } = {},
|
||||
) {
|
||||
let anchor = initialAnchor;
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
@@ -45,9 +46,20 @@ function harness(
|
||||
advance(ms) {
|
||||
clock += ms;
|
||||
},
|
||||
now: () => clock,
|
||||
setAnchor(value) {
|
||||
anchor = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
const threeHealthy = () => [
|
||||
row('a1', 1.1, false),
|
||||
row('a2', 1.2, false),
|
||||
row('a3', 1.3, false),
|
||||
];
|
||||
|
||||
test('the layer requires a snapshot source and cannot be initialized twice', () => {
|
||||
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
|
||||
const { layer } = harness(async () => ({ rows: [] }));
|
||||
@@ -206,6 +218,72 @@ test('no view anchor means no request, and destroy releases the data source', as
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
||||
// The proxy keeps answering with the snapshot it observed at t=1000.
|
||||
const { layer, sources, advance } = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
fetchedAt: 1_000,
|
||||
stale: true,
|
||||
}));
|
||||
layer.enable();
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(29 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(2 * MIN);
|
||||
assert.equal(
|
||||
await layer.update(),
|
||||
true,
|
||||
'the replay is not an error, only no evidence',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
0,
|
||||
'the replay did not restart the window',
|
||||
);
|
||||
assert.equal(sources[0].entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('cells expire during prolonged fetch failures', async () => {
|
||||
let fail = false;
|
||||
const { layer, sources, advance, now } = harness(async () => {
|
||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||
return { rows: threeHealthy(), fetchedAt: now() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
fail = true;
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), false);
|
||||
assert.equal(layer.getStats().count, 1, 'still inside the window');
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), false);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].entities.values.length, 0);
|
||||
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||
});
|
||||
|
||||
test('cells expire while no view anchor is available', async () => {
|
||||
let calls = 0;
|
||||
const { layer, sources, advance, now, setAnchor } = harness(async () => {
|
||||
calls += 1;
|
||||
return { rows: threeHealthy(), fetchedAt: now() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
setAnchor(null);
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(calls, 1, 'no anchor means no request');
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].entities.values.length, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
});
|
||||
|
||||
const threeCells = () => ({
|
||||
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
|
||||
});
|
||||
|
||||
+65
-21
@@ -21,6 +21,8 @@
|
||||
|
||||
/** Direct 1090ES ADS-B only: MLAT, TIS-B and ADS-R positions are not self-reported. */
|
||||
const QUALIFYING_TYPES = new Set(['adsb_icao']);
|
||||
/** DO-260A (1) and DO-260B (2) define NIC and NACp; version 0 and unknown versions do not. */
|
||||
const QUALIFYING_VERSIONS = new Set([1, 2]);
|
||||
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
|
||||
export const GNSS_NIC_THRESHOLD = 7;
|
||||
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
|
||||
@@ -113,14 +115,16 @@ export function normalizeGnssAircraft(payload) {
|
||||
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 (!QUALIFYING_VERSIONS.has(aircraft.version)) 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;
|
||||
// Only a position confirmed within the last minute is current evidence; a
|
||||
// missing, invalid or negative age confirms nothing.
|
||||
const age = aircraft.seen_pos;
|
||||
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
|
||||
// readsb sets gpsOkBefore when an aircraft's integrity collapses and keeps
|
||||
// it for roughly 15 minutes after GPS recovers, so it can outlast the
|
||||
// current NIC/NACp; the rolling window treats that as recent evidence.
|
||||
@@ -149,34 +153,72 @@ export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* once per cell per window. Presence and degraded evidence age separately: a
|
||||
* degraded report keeps the aircraft degraded in that cell for one window
|
||||
* after the report itself, however many healthy reports follow, so an
|
||||
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
|
||||
* is not held degraded forever.
|
||||
*
|
||||
* @param {Map<string, {cell: string, degraded: boolean, seenAt: number}>} store
|
||||
* Every row is dated by when the snapshot was observed, not when it arrived:
|
||||
* replaying a cached or stale snapshot never makes its evidence younger, and
|
||||
* a snapshot older than the window adds nothing.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number, cellDeg?: number}} [options]
|
||||
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
|
||||
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
|
||||
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
|
||||
* @returns {Map} The same store, pruned to the window.
|
||||
*/
|
||||
export function accumulateGnssObservations(
|
||||
store,
|
||||
rows,
|
||||
nowMs,
|
||||
{ windowMs = GNSS_WINDOW_MS, cellDeg = GNSS_CELL_DEG } = {},
|
||||
{
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
cellDeg = GNSS_CELL_DEG,
|
||||
observedAt = nowMs,
|
||||
} = {},
|
||||
) {
|
||||
for (const row of rows || []) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
|
||||
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,
|
||||
});
|
||||
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
|
||||
if (Number.isFinite(at) && nowMs - at <= windowMs) {
|
||||
for (const row of rows || []) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
|
||||
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
|
||||
const key = `${row.hex}|${cell}`;
|
||||
const previous = store.get(key);
|
||||
const previousDegradedAt = previous?.degradedAt ?? null;
|
||||
store.set(key, {
|
||||
cell,
|
||||
seenAt: Math.max(previous?.seenAt ?? at, at),
|
||||
degradedAt: row.degraded
|
||||
? Math.max(previousDegradedAt ?? at, at)
|
||||
: previousDegradedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pruneGnssObservations(store, nowMs, { windowMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop observations older than the window and degraded evidence older than
|
||||
* the window, without adding anything. Used when no fresh snapshot arrives.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number}} [options]
|
||||
* @returns {Map} The same store.
|
||||
*/
|
||||
export function pruneGnssObservations(
|
||||
store,
|
||||
nowMs,
|
||||
{ windowMs = GNSS_WINDOW_MS } = {},
|
||||
) {
|
||||
for (const [key, entry] of store) {
|
||||
if (nowMs - entry.seenAt > windowMs) store.delete(key);
|
||||
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
|
||||
store.set(key, { ...entry, degradedAt: null });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
@@ -205,7 +247,9 @@ export function gnssIntegrityLevel(total, degraded) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the observation store into displayable cells.
|
||||
* Aggregate the observation store into displayable cells. Expects a store
|
||||
* already pruned to the window by `accumulateGnssObservations` or
|
||||
* `pruneGnssObservations`.
|
||||
*
|
||||
* @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', state: string, interpretation: string|null}[]}
|
||||
*/
|
||||
@@ -214,10 +258,10 @@ export function binGnssCells(
|
||||
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
|
||||
) {
|
||||
const tallies = new Map();
|
||||
for (const { cell, degraded } of store.values()) {
|
||||
for (const { cell, degradedAt } of store.values()) {
|
||||
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
|
||||
tally.aircraft += 1;
|
||||
if (degraded) tally.degraded += 1;
|
||||
if (degradedAt != null) tally.degraded += 1;
|
||||
tallies.set(cell, tally);
|
||||
}
|
||||
const cells = [];
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
binGnssCells,
|
||||
GNSS_CLASSIFIER,
|
||||
GNSS_PROVENANCE,
|
||||
GNSS_WINDOW_MS,
|
||||
gnssCellKey,
|
||||
gnssIntegrityLevel,
|
||||
gnssProvenance,
|
||||
normalizeGnssAircraft,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
|
||||
const aircraft = (overrides = {}) => ({
|
||||
@@ -36,10 +38,24 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
|
||||
aircraft({ hex: '000006', nic: undefined }),
|
||||
aircraft({ hex: '000007', lat: 95 }),
|
||||
aircraft({ hex: '000008', version: undefined }),
|
||||
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
|
||||
aircraft({ hex: '000009', version: 3 }),
|
||||
aircraft({ hex: '00000a', version: '2' }),
|
||||
aircraft({ hex: '00000b', version: 1.5 }),
|
||||
// A position is current only when its age is known, finite and 0..60 s.
|
||||
aircraft({ hex: '00000c', seen_pos: undefined }),
|
||||
aircraft({ hex: '00000d', seen_pos: null }),
|
||||
aircraft({ hex: '00000e', seen_pos: -0.5 }),
|
||||
aircraft({ hex: '00000f', seen_pos: '1.2' }),
|
||||
aircraft({ hex: '000010', seen_pos: Number.NaN }),
|
||||
aircraft({ hex: '000011', seen_pos: 60.1 }),
|
||||
aircraft({ hex: 'not-hex' }),
|
||||
aircraft(),
|
||||
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
|
||||
aircraft({ hex: '~a1b2c3' }),
|
||||
// Version 1, and both ends of the position-age range, qualify.
|
||||
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
|
||||
aircraft({ hex: 'b00002', seen_pos: 60 }),
|
||||
null,
|
||||
],
|
||||
});
|
||||
@@ -62,6 +78,15 @@ test('normalization keeps only airborne self-reported ADS-B v1/v2 positions', ()
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
},
|
||||
...['b00001', 'b00002'].map((hex) => ({
|
||||
hex,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
})),
|
||||
]);
|
||||
assert.equal(normalizeGnssAircraft({}), null);
|
||||
assert.equal(normalizeGnssAircraft(null), null);
|
||||
@@ -205,6 +230,134 @@ test('observations count each aircraft once per cell and expire after the window
|
||||
assert.deepEqual(binGnssCells(store), []);
|
||||
});
|
||||
|
||||
const MIN = 60_000;
|
||||
const report = (hex, degraded, lat = 50.1) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon: 20.1,
|
||||
degraded,
|
||||
});
|
||||
const entry = (store, hex) =>
|
||||
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
|
||||
|
||||
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 keeps reporting healthy every minute in the same cell.
|
||||
for (let minute = 1; minute <= 30; minute += 1)
|
||||
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
|
||||
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
|
||||
assert.equal(
|
||||
entry(store, 'a1').degradedAt,
|
||||
0,
|
||||
'healthy reports do not refresh the degraded evidence',
|
||||
);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 31 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// A new degraded report starts its own window.
|
||||
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
});
|
||||
|
||||
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
|
||||
const store = new Map();
|
||||
const healthy = [report('a2', false), report('a3', false)];
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', true), report('b1', true), ...healthy],
|
||||
0,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].level, 'high');
|
||||
for (let minute = 10; minute <= 30; minute += 10)
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
minute * MIN,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].degraded, 2);
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
31 * MIN,
|
||||
);
|
||||
const [cell] = binGnssCells(store);
|
||||
assert.equal(cell.aircraft, 4);
|
||||
assert.equal(cell.degraded, 0);
|
||||
assert.equal(cell.level, 'low');
|
||||
});
|
||||
|
||||
test('an aircraft returning after the window starts fresh', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 leaves; nothing is heard from it for longer than the window.
|
||||
accumulateGnssObservations(store, [], 31 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 32 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// Returning degraded dates the evidence from the new report.
|
||||
accumulateGnssObservations(store, [report('a9', true)], 0);
|
||||
accumulateGnssObservations(store, [], 31 * MIN);
|
||||
accumulateGnssObservations(store, [report('a9', true)], 33 * MIN);
|
||||
assert.equal(entry(store, 'a9').degradedAt, 33 * MIN);
|
||||
});
|
||||
|
||||
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
|
||||
const store = new Map();
|
||||
const snapshot = [report('a1', true), report('a2', false)];
|
||||
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
|
||||
// The same snapshot replayed (cached or stale) 29 minutes later.
|
||||
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
|
||||
assert.equal(entry(store, 'a1').seenAt, 0);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 0);
|
||||
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
|
||||
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
|
||||
|
||||
// An older replay never moves an entry's age backwards either.
|
||||
accumulateGnssObservations(store, snapshot, 40 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
|
||||
observedAt: 35 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
|
||||
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
|
||||
|
||||
// A future time is clamped to now; an undated snapshot adds nothing.
|
||||
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
|
||||
observedAt: 99 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
|
||||
for (const observedAt of [null, Number.NaN])
|
||||
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
|
||||
observedAt,
|
||||
});
|
||||
assert.equal(entry(store, 'u1'), undefined);
|
||||
});
|
||||
|
||||
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', false)], 0);
|
||||
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
|
||||
assert.deepEqual(
|
||||
[...store.keys()].map((key) => key.split('|')[0]),
|
||||
['a1'],
|
||||
);
|
||||
assert.equal(entry(store, 'a1').degradedAt, null);
|
||||
pruneGnssObservations(store, 51 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
|
||||
});
|
||||
|
||||
test('rows without finite coordinates never key a cell', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
|
||||
@@ -25,11 +25,13 @@ export function createAdsbGnssSource({
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed GNSS integrity snapshot');
|
||||
// The observation time dates every row in the rolling window; an
|
||||
// undated snapshot could only be treated as new, so it is refused.
|
||||
if (!Number.isFinite(payload.fetchedAt))
|
||||
throw new Error('GNSS integrity snapshot has no observation time');
|
||||
return {
|
||||
rows: payload.rows,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
: null,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
stale: payload.stale === true,
|
||||
classifier:
|
||||
typeof payload.classifier === 'string' ? payload.classifier : null,
|
||||
|
||||
@@ -31,11 +31,19 @@ test('the source asks the same-origin proxy for the view anchor', async () => {
|
||||
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 }),
|
||||
fetchImpl: reply({ rows: [], fetchedAt: 7, stale: true }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, null);
|
||||
assert.equal(stale.fetchedAt, 7, 'a stale reply keeps its observation time');
|
||||
assert.equal(stale.classifier, null);
|
||||
// Without an observation time the rows could only be dated as new.
|
||||
for (const fetchedAt of [undefined, null, '7'])
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({
|
||||
fetchImpl: reply({ rows: [], fetchedAt }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||
/no observation time/,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
|
||||
|
||||
Reference in new issue
Block a user