From c390724bc0dcf6f0f15112c0dcf6e5bb038350a8 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:46:01 -0400 Subject: [PATCH 1/5] fix(earthquakes): validate snapshots before replacing entities --- CHANGELOG.md | 4 +++ docs/CURRENT-STATE.md | 4 +++ src/data/earthquakes.js | 51 +++++++++++++++++++++++++---------- src/data/earthquakes.test.mjs | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa46..fc58e8fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861f4..bfd9683ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 18767adb2..476b72c18 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -121,6 +121,34 @@ export function mapAnalystRecord(raw, index = 0) { }; } +/** Validate a complete feed before replacing the last good earthquake snapshot. */ +export function normalizeEarthquakeSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const [index, feature] of geojson.features.entries()) { + const coordinates = feature?.geometry?.coordinates; + const properties = feature?.properties; + if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties + || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null; + const [lon, lat, depthKm] = coordinates; + const mag = properties.mag; + if (!Number.isFinite(lon) || Math.abs(lon) > 180 + || !Number.isFinite(lat) || Math.abs(lat) > 90 + || (depthKm != null && !Number.isFinite(depthKm)) + || (mag != null && (!Number.isFinite(mag) || mag > 10))) return null; + // A missing magnitude cannot establish that this event meets M2.5+. + if (mag == null || mag < 2.5) continue; + const stableId = feature.id == null || feature.id === '' ? `event-${index + 1}` : String(feature.id); + if (ids.has(stableId)) return null; + ids.add(stableId); + rows.push({ stableId, usgsId: feature.id ?? null, lon, lat, depthKm: depthKm ?? null, + mag, place: typeof properties.place === 'string' ? properties.place : null, + time: Number.isFinite(properties.time) ? properties.time : null }); + } + return rows; +} + export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) { let _dataSource = null; let _count = 0; @@ -172,23 +200,17 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = } const geojson = await response.json(); - if (!geojson || !Array.isArray(geojson.features)) { + const rows = normalizeEarthquakeSnapshot(geojson); + if (!rows) { _lastError = 'Malformed USGS response'; return false; } - _dataSource.entities.removeAll(); + const nextEntities = []; let count = 0; const overlayEntries = []; - for (const feature of geojson.features) { - const [lon, lat, depthKm] = feature.geometry.coordinates; - const mag = feature.properties.mag; - const place = feature.properties.place; - const time = feature.properties.time; - - if (mag < 2.5) continue; // Skip micro-quakes - + for (const { stableId, usgsId, lon, lat, depthKm, mag, place, time } of rows) { count++; const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); @@ -197,8 +219,7 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = const outlineAlpha = isSignificant ? 1.0 : 0.8; const position = Cesium.Cartesian3.fromDegrees(lon, lat); - const stableId = feature.id || `event-${count}`; - _dataSource.entities.add({ + nextEntities.push(new Cesium.Entity({ id: `earthquake:${stableId}`, position, ellipse: { @@ -216,13 +237,13 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = }, properties: { // Analyst seam (additive): the USGS event id (e.g. "us7000abcd"). - usgsId: feature.id ?? null, + usgsId, mag, place, time, depth: depthKm, }, - }); + })); overlayEntries.push(createEarthquakeOverlayEntry({ id: String(stableId), position, @@ -231,6 +252,8 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = })); } + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); if (_enabled) { overlayHost.setEntries( EARTHQUAKE_OVERLAY_SOURCE_ID, diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 3e2e91fe1..cee572ee3 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -338,3 +338,53 @@ test('earthquake refresh reports failure and clears it only after a successful r layer.destroy(viewer); } }); + +test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => { + const originalFetch = globalThis.fetch; + const dataSources = []; + const publications = []; + const viewer = { dataSources: { + add(source) { dataSources.push(source); }, remove() { return true; }, + } }; + const layer = createEarthquakesLayer({ overlayHost: { + setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {}, + } }); + const good = { id: 'good', geometry: { type: 'Point', coordinates: [10, 20, 5] }, + properties: { mag: 4, time: 100, place: 'Fixture' } }; + const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); }; + try { + layer.init(viewer); + layer.enable(viewer); + respond([good]); + assert.equal(await layer.update(viewer), true); + const entity = dataSources[0].entities.values[0]; + const stats = layer.getStats(); + for (const bad of [null, { ...good, geometry: null }, + { ...good, geometry: { type: 'LineString', coordinates: [1, 2] } }, + { ...good, geometry: { coordinates: [181, 20] } }, + { ...good, geometry: { coordinates: [10, -91] } }, + { ...good, geometry: { coordinates: [null, 20] } }, + { ...good, properties: { mag: '4' } }, + { ...good, properties: { mag: Infinity } }, good]) { + respond([good, bad]); + assert.equal(await layer.update(viewer), false); + assert.equal(dataSources[0].entities.values.length, 1); + assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(layer.getStats().count, stats.count); + assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); + assert.equal(layer.getStats().error, 'Malformed USGS response'); + assert.equal(publications.length, 1); + } + respond([{ ...good, properties: { mag: null } }]); + assert.equal(await layer.update(viewer), true); + assert.equal(dataSources[0].entities.values.length, 0); + assert.equal(layer.getStats().error, null); + assert.equal(publications.at(-1)[1].length, 0); + respond([good]); + assert.equal(await layer.update(viewer), true); + assert.equal(dataSources[0].entities.values.length, 1); + } finally { + globalThis.fetch = originalFetch; + layer.destroy(viewer); + } +}); From ac3926c7dba5a2435e40e4b3748b36c729416cba Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 10 Sep 2026 19:56:01 -0700 Subject: [PATCH 2/5] fix(earthquakes): reject malformed feature properties --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/data/earthquakes.js | 1 + src/data/earthquakes.test.mjs | 13 +++++++++++++ 4 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc58e8fd8..0d2fe2a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfd9683ae..4132fde82 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,8 @@ Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. +Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 476b72c18..cb4d3ac13 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -130,6 +130,7 @@ export function normalizeEarthquakeSnapshot(geojson) { const coordinates = feature?.geometry?.coordinates; const properties = feature?.properties; if (!Array.isArray(coordinates) || coordinates.length < 2 || !properties + || typeof properties !== 'object' || Array.isArray(properties) || (feature.geometry.type != null && feature.geometry.type !== 'Point')) return null; const [lon, lat, depthKm] = coordinates; const mag = properties.mag; diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index cee572ee3..8119a6dc7 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -359,6 +359,19 @@ test('malformed earthquake refresh preserves entities, overlays, count and times assert.equal(await layer.update(viewer), true); const entity = dataSources[0].entities.values[0]; const stats = layer.getStats(); + for (const properties of ['invalid', 123, [], true]) { + const bad = { ...good, id: 'bad-properties', properties }; + for (const features of [[bad], [good, bad]]) { + respond(features); + assert.equal(await layer.update(viewer), false); + assert.equal(dataSources[0].entities.values.length, 1); + assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(layer.getStats().count, stats.count); + assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); + assert.equal(layer.getStats().error, 'Malformed USGS response'); + assert.equal(publications.length, 1); + } + } for (const bad of [null, { ...good, geometry: null }, { ...good, geometry: { type: 'LineString', coordinates: [1, 2] } }, { ...good, geometry: { coordinates: [181, 20] } }, From a4b6dbb41f30e2b3c0e9127bbfc465bd48c784a5 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:29:55 -0400 Subject: [PATCH 3/5] fix(launches): preserve unknown payload details --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/data/rocketLaunches.js | 8 +++++--- src/data/rocketLaunches.test.mjs | 13 +++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2fe2a0a..2ba0007ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Earthquake refreshes validate the complete feed and construct replacement entiti Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4132fde82..069274f22 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6,6 +6,8 @@ Earthquake refreshes validate the complete feed and construct replacement entiti Non-object or array-valued properties reject the response instead of being treated as an unknown magnitude. +Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index bd9bf79c7..e8fda9b37 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -2106,13 +2106,15 @@ function normalizePayloadFlights(launch) { const payload = flight.payload || flight; return { id: String(flight.id || payload.id || `payload-${index}`), - name: payload.name || flight.name || 'Undisclosed payload', + name: payload.name || flight.name || 'Unnamed payload', type: payload.type?.name || flight.type?.name || null, manufacturer: payload.manufacturer?.name || null, operator: payload.operator?.name || null, destination: flight.destination || payload.destination || null, amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1, - massKg: Number.isFinite(Number(payload.mass)) ? Number(payload.mass) : null, + massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== '')) + && Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0 + ? Number(payload.mass) : null, }; }); } @@ -2288,7 +2290,7 @@ function renderMissionPanel() { _missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows( payloadRows, 3, - 'CLASSIFIED / MULTI-PAYLOAD', + 'PAYLOAD DATA UNAVAILABLE', ); const stageRows = launch.recoveryStages.map((stage) => { const endpoint = stage.endpoint; diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index dd1ae4c3c..27d29ff12 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -1253,3 +1253,16 @@ test('disable reports a semantic failure while restoring the satellites dependen /could not restore the satellites layer/, ); }); + + +test('missing payload details stay unknown without inventing mass or classification', () => { + for (const [mass, expected] of [[null, null], [undefined, null], ['', null], [' ', null], + [false, null], [-1, null], ['invalid', null], [0, 0], ['125', 125]]) { + const [launch] = normalizeRocketLaunches([{ id: 'mass', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z', + payloads: [{ mass }] }], NOW); + assert.equal(launch.payloads[0].massKg, expected); + assert.equal(launch.payloads[0].name, 'Unnamed payload'); + } + const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW); + assert.deepEqual(launch.payloads, []); +}); From 87a92c8bc4730eedfce8ab2f22b4242ab8bd8819 Mon Sep 17 00:00:00 2001 From: Malek Date: Fri, 11 Sep 2026 05:01:39 +0100 Subject: [PATCH 4/5] fix(radio): resolve common country exonyms Intl.DisplayNames omits (#232) Recognize common country names and exonyms in Radio country requests and station filtering. Keep ambiguous country names rejected. --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 8 +++++--- src/data/radioCountry.js | 16 ++++++++++++++++ src/data/radioCountry.test.mjs | 31 +++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba0007ab..074c6f754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- Radio country filtering and voice country requests now resolve common English + names and exonyms that `Intl.DisplayNames`' primary label omits, so requests + like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus + Myanmar/Burma, UAE, Holland, Swaziland, East Timor, Cabo Verde, Vatican). + Ambiguous names such as a bare "Congo" or "Korea" still fail closed. - Mapped-site outages show their scheduled retry countdown and distinguish known Overpass rate limits, timeouts, and query failures. Search feedback no longer claims a refresh succeeded while the layer is unavailable or loading. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 069274f22..e85c38db0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1692,9 +1692,11 @@ preserves object identity for an idempotent repeat of the same generation, and degrades without replacement if a fresh response presents an older generation. Snapshot records mark community metadata as untrusted, and Radio tool results omit station names so directory text never becomes model instruction context. -One bounded country parser maps recognized ISO codes and English/common names -through proxy metadata and final station selection, while malformed, non-ISO, -control-containing, and oversized inputs fail closed. Literal or resolved +One bounded country parser maps recognized ISO codes and English/common names — +including widely used exonyms the Intl display label omits (Turkey, Holland, +Burma, and similar) — through proxy metadata and final station selection, while +malformed, non-ISO, control-containing, ambiguous, and oversized inputs fail +closed. Literal or resolved non-global IPv4/IPv6 targets are refused. Destroy fully releases the Radio audio session, voice ducking/restoration, request state, filter, selection, volume, accepted snapshot, and feed telemetry before re-initialization; monotonic diff --git a/src/data/radioCountry.js b/src/data/radioCountry.js index f72a50f4d..6b056ce10 100644 --- a/src/data/radioCountry.js +++ b/src/data/radioCountry.js @@ -55,6 +55,22 @@ for (const [name, code] of Object.entries({ brunei: 'BN', 'cape verde': 'CV', 'the netherlands': 'NL', + // Common English names and exonyms whose code is not reachable through the + // Intl.DisplayNames primary label, so they would otherwise fail closed + // (verified against ICU 77): TR resolves only as 'Türkiye', MM only as + // 'Myanmar (Burma)', AE only as 'United Arab Emirates', SZ as 'Eswatini', + // TL as 'Timor-Leste'. Each maps to exactly one country; ambiguous names + // (e.g. bare "Congo") are deliberately left to fail closed. + turkey: 'TR', + turkiye: 'TR', + myanmar: 'MM', + burma: 'MM', + uae: 'AE', + holland: 'NL', + swaziland: 'SZ', + 'east timor': 'TL', + 'cabo verde': 'CV', + vatican: 'VA', })) COUNTRY_NAME_TO_CODE.set(countryKey(name), code); function canonicalCountryName(code) { diff --git a/src/data/radioCountry.test.mjs b/src/data/radioCountry.test.mjs index 70511de87..3270b46dc 100644 --- a/src/data/radioCountry.test.mjs +++ b/src/data/radioCountry.test.mjs @@ -18,6 +18,37 @@ test('Radio country normalization maps ISO codes and bounded common names', () = } }); +test('Radio country normalization resolves common English names/exonyms ICU misses', () => { + // Each of these fails closed against the Intl.DisplayNames primary label + // alone (e.g. TR is "Türkiye", MM is "Myanmar (Burma)", AE is "United Arab + // Emirates"), so a request like "play radio in Turkey" would return nothing. + for (const [input, code] of [ + ['Turkey', 'TR'], + ['Turkiye', 'TR'], + ['Myanmar', 'MM'], + ['Burma', 'MM'], + ['UAE', 'AE'], + ['U.A.E.', 'AE'], + ['Holland', 'NL'], + ['Swaziland', 'SZ'], + ['East Timor', 'TL'], + ['Cabo Verde', 'CV'], + ['Vatican', 'VA'], + ]) { + const result = normalizeRadioCountryInput(input); + assert.equal(result.valid, true, input); + assert.equal(result.code, code, input); + } +}); + +test('Ambiguous country names still fail closed (no broadened selection)', () => { + // Two states share the name "Congo" (CD/CG), so a bare mention must not + // resolve to either — it stays low-confidence and fails closed. + for (const input of ['Congo', 'Korea']) { + assert.equal(normalizeRadioCountryInput(input).valid, false, input); + } +}); + test('Radio country normalization rejects malformed, non-ISO, and oversized values', () => { for (const input of [ 'ZZ', From e7cb08f5f6ed7d43bf5a5e204203f4ac5573873b Mon Sep 17 00:00:00 2001 From: mwilliamsGH Date: Fri, 11 Sep 2026 00:13:46 -0400 Subject: [PATCH 5/5] fix(firms): record source success after aggregation (#181) Record FIRMS source success after appending its rows so aggregation failures do not produce contradictory source status. Cover large feeds, filtering, ordering and source failures with regression tests. --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 7 ++++ src/data/firmsProxy.test.mjs | 80 ++++++++++++++++++++++++++++++++++++ vite.config.js | 2 +- 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/data/firmsProxy.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 074c6f754..e2dc1ec2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Fixed +- FIRMS records a source as successful only after appending its rows, avoiding + contradictory success/failure status if aggregation throws. - Radio country filtering and voice country requests now resolve common English names and exonyms that `Intl.DisplayNames`' primary label omits, so requests like "play radio in Turkey" no longer fail closed (Turkey → Türkiye, plus diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e85c38db0..b70fdf86a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,6 +10,13 @@ Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing n Updated: August 24, 2026 +## FIRMS source status + +The FIRMS proxy records source success after appending its rows. If aggregation +throws, that source reports failure without a contradictory success entry. +The existing per-record append continues to support large feeds; sequential +fetching, trailing-24-hour filtering and partial-success caching are unchanged. + ## Installations and map-source guidance - On an uncached Overpass failure, mapped installations keep their existing diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs new file mode 100644 index 000000000..5de363090 --- /dev/null +++ b/src/data/firmsProxy.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { filterTrailing24h, parseFirmsCsv } from './firmsCsv.js'; + +const config = fs.readFileSync(new URL('../../vite.config.js', import.meta.url), 'utf8'); +const start = config.indexOf(' async function refreshUpstream(key) {'); +assert.notEqual(start, -1, 'FIRMS refresh function exists'); +const end = config.indexOf('\n }', start); +assert.notEqual(end, -1, 'FIRMS refresh function closes'); +const refreshSource = config.slice(start, end + 4); +const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT']; +const NOW = Date.UTC(2026, 8, 11, 12); +const recent = { acqDate: '2026-09-11', acqTime: '1100' }; + +// Exercise the production refresh without opening a server or using a MAP_KEY. +// Inject only its upstream, clock and filter dependencies; keep its aggregation +// and source-status code intact, including failures while consuming records. +function createRefresh(fetchSource, filter = filterTrailing24h) { + return new Function('SOURCES', 'fetchSource', 'filterTrailing24h', 'Date', 'console', + `return (${refreshSource});`)(SOURCES, fetchSource, filter, { now: () => NOW }, { warn() {} }); +} + +test('FIRMS retains large sources in order and filters expired rows', async () => { + const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n'; + const large = parseFirmsCsv(header + '1,2,2026-09-11,1100,n,4\n'.repeat(200_000)); + const expired = { ...recent, acqDate: '2026-09-09' }; + const last = { ...recent, marker: 'last' }; + const calls = []; + let active = 0; + const refresh = createRefresh(async (key, source) => { + assert.equal(active++, 0, 'sources must be fetched sequentially'); + calls.push(source); + await Promise.resolve(); + active--; + return source === SOURCES[0] ? [...large, expired] : source === SOURCES[1] ? [last] : []; + }); + const result = await refresh('fixture'); + assert.deepEqual(calls, SOURCES); + assert.equal(result.fires.length, 200_001); + assert.equal(result.fires[0], large[0]); + assert.equal(result.fires[199_999], large.at(-1)); + assert.equal(result.fires.at(-1), last); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: [200_000, 1, 0][index], ok: true, + }))); +}); + +test('FIRMS keeps successful sources when another upstream fails', async () => { + const result = await createRefresh(async (key, source) => { + if (source === SOURCES[1]) throw new Error('upstream unavailable'); + return [recent]; + })('fixture'); + assert.equal(result.fires.length, 2); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: index === 1 ? 0 : 1, ok: index !== 1, + }))); +}); + +test('FIRMS reports one failure if consuming a source throws before append', async () => { + const failing = []; + const result = await createRefresh(async (key, source) => source === SOURCES[0] ? failing : [recent], + (records, now) => { + if (records !== failing) return filterTrailing24h(records, now); + // Fault injection for aggregation; ordinary parsed CSV returns an array. + return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } }; + })('fixture'); + assert.equal(result.fires.length, 2); + assert.deepEqual(result.sources, SOURCES.map((source, index) => ({ + source, count: index === 0 ? 0 : 1, ok: index !== 0, + }))); +}); + +test('FIRMS distinguishes all-source failure from successful empty sources', async () => { + await assert.rejects(createRefresh(async () => { throw new Error('upstream unavailable'); })('fixture'), + /all FIRMS sources failed/); + const result = await createRefresh(async () => [])('fixture'); + assert.deepEqual(result.fires, []); + assert.deepEqual(result.sources, SOURCES.map(source => ({ source, count: 0, ok: true }))); +}); diff --git a/vite.config.js b/vite.config.js index 34dac5291..0ad4f2c37 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2094,11 +2094,11 @@ function firmsProxy() { for (const source of SOURCES) { try { const records = filterTrailing24h(await fetchSource(key, source), now); - sources.push({ source, count: records.length, ok: true }); // NOT fires.push(...records): spread passes each record as an argument, // and a world/2 VIIRS pull exceeds V8's argument limit (~125k) at // ~131k records — RangeError, and the whole source is silently dropped. for (const record of records) fires.push(record); + sources.push({ source, count: records.length, ok: true }); } catch (err) { console.warn(`[firms-proxy] ${source} fetch failed:`, err?.message || err); sources.push({ source, count: 0, ok: false });