From f60eeacb2d76d2b8254cdc55708c6ab077419266 Mon Sep 17 00:00:00 2001 From: djabhi31 Date: Fri, 16 Oct 2026 19:12:46 +0530 Subject: [PATCH 1/4] test(earthquakes): add unit test coverage for earthquake snapshot normalization - Pin GeoJSON earthquake snapshot normalization contract in src/layers/earthquakes/records.test.mjs - Cover complete-feed validation, M2.5 magnitude filtering, coordinate and depth bounds - Verify stable fallback IDs, duplicate ID rejection, and malformed structure handling - Register records.test.mjs in scripts/format-scope.json and apply Prettier formatting --- scripts/format-scope.json | 1 + src/layers/earthquakes/records.test.mjs | 261 ++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/layers/earthquakes/records.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 34e12d6c2..bbc7960a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -722,6 +722,7 @@ "src/voice/session.test.mjs", "src/layers/alpr/records.js", "src/layers/earthquakes/records.js", + "src/layers/earthquakes/records.test.mjs", "src/sources/sourceSlot.js", "src/data/feedState.js", "src/app/localGeojsonServices.js", diff --git a/src/layers/earthquakes/records.test.mjs b/src/layers/earthquakes/records.test.mjs new file mode 100644 index 000000000..cd7b6a874 --- /dev/null +++ b/src/layers/earthquakes/records.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeEarthquakeSnapshot } from './records.js'; + +test('normalizeEarthquakeSnapshot parses valid GeoJSON earthquake features', () => { + const geojson = { + type: 'FeatureCollection', + features: [ + { + id: 'us7000m123', + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.4194, 37.7749, 10.5], + }, + properties: { + mag: 4.2, + place: 'San Francisco Bay Area, CA', + time: 1711929600000, + }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(Array.isArray(rows), true); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + stableId: 'us7000m123', + usgsId: 'us7000m123', + lon: -122.4194, + lat: 37.7749, + depthKm: 10.5, + mag: 4.2, + place: 'San Francisco Bay Area, CA', + time: 1711929600000, + }); +}); + +test('normalizeEarthquakeSnapshot generates index-based stable IDs when id is missing or blank', () => { + const geojson = { + features: [ + { + id: null, + geometry: { type: 'Point', coordinates: [139.6917, 35.6895, 25.0] }, + properties: { mag: 3.5, place: 'Tokyo, Japan', time: 1711930000000 }, + }, + { + id: '', + geometry: { type: 'Point', coordinates: [-70.6693, -33.4489, 45.2] }, + properties: { mag: 5.1, place: 'Santiago, Chile', time: 1711931000000 }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 2); + assert.equal(rows[0].stableId, 'event-1'); + assert.equal(rows[0].usgsId, null); + assert.equal(rows[1].stableId, 'event-2'); + assert.equal(rows[1].usgsId, ''); +}); + +test('normalizeEarthquakeSnapshot filters events below M2.5 without dropping the feed', () => { + const geojson = { + features: [ + { + id: 'ev-sub', + geometry: { type: 'Point', coordinates: [10, 20, 5] }, + properties: { mag: 2.49, place: 'Minor tremor' }, + }, + { + id: 'ev-null-mag', + geometry: { type: 'Point', coordinates: [11, 21, 5] }, + properties: { mag: null, place: 'Uncalculated' }, + }, + { + id: 'ev-threshold', + geometry: { type: 'Point', coordinates: [12, 22, 5] }, + properties: { mag: 2.5, place: 'Threshold event' }, + }, + { + id: 'ev-strong', + geometry: { type: 'Point', coordinates: [13, 23, 5] }, + properties: { mag: 6.8, place: 'Major event' }, + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 2); + assert.equal(rows[0].stableId, 'ev-threshold'); + assert.equal(rows[0].mag, 2.5); + assert.equal(rows[1].stableId, 'ev-strong'); + assert.equal(rows[1].mag, 6.8); +}); + +test('normalizeEarthquakeSnapshot normalizes optional fields to null when omitted or invalid', () => { + const geojson = { + features: [ + { + id: 'ev-sparse', + geometry: { coordinates: [-100, 40] }, // depth omitted + properties: { mag: 3.0, place: 12345, time: 'invalid' }, // place not string, time not number + }, + ], + }; + + const rows = normalizeEarthquakeSnapshot(geojson); + assert.equal(rows.length, 1); + assert.equal(rows[0].depthKm, null); + assert.equal(rows[0].place, null); + assert.equal(rows[0].time, null); +}); + +test('normalizeEarthquakeSnapshot returns null for malformed structures or non-array features', () => { + assert.equal(normalizeEarthquakeSnapshot(null), null); + assert.equal(normalizeEarthquakeSnapshot(undefined), null); + assert.equal(normalizeEarthquakeSnapshot({}), null); + assert.equal(normalizeEarthquakeSnapshot({ features: 'not-an-array' }), null); + assert.equal(normalizeEarthquakeSnapshot({ features: null }), null); +}); + +test('normalizeEarthquakeSnapshot returns null for invalid geometries or out-of-range coordinates', () => { + const baseFeature = { + id: 'test', + geometry: { type: 'Point', coordinates: [0, 0, 0] }, + properties: { mag: 3.0 }, + }; + + // Non-Point geometry + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1], + ], + }, + }, + ], + }), + null, + ); + + // Missing or short coordinates + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { ...baseFeature, geometry: { type: 'Point', coordinates: [0] } }, + ], + }), + null, + ); + + // Longitude out of range [-180, 180] + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [180.1, 0, 0] }, + }, + ], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [-180.1, 0, 0] }, + }, + ], + }), + null, + ); + + // Latitude out of range [-90, 90] + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, 90.1, 0] }, + }, + ], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, -90.1, 0] }, + }, + ], + }), + null, + ); + + // Non-finite depth + assert.equal( + normalizeEarthquakeSnapshot({ + features: [ + { + ...baseFeature, + geometry: { type: 'Point', coordinates: [0, 0, NaN] }, + }, + ], + }), + null, + ); + + // Magnitude > 10 + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: { mag: 10.1 } }], + }), + null, + ); + + // Non-object or array properties + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: 'invalid' }], + }), + null, + ); + assert.equal( + normalizeEarthquakeSnapshot({ + features: [{ ...baseFeature, properties: [] }], + }), + null, + ); +}); + +test('normalizeEarthquakeSnapshot returns null when feed contains duplicate IDs', () => { + const geojson = { + features: [ + { + id: 'duplicate-id', + geometry: { type: 'Point', coordinates: [0, 0, 0] }, + properties: { mag: 3.0 }, + }, + { + id: 'duplicate-id', + geometry: { type: 'Point', coordinates: [1, 1, 1] }, + properties: { mag: 4.0 }, + }, + ], + }; + + assert.equal(normalizeEarthquakeSnapshot(geojson), null); +}); From 0d8caaafd8c245dda6524ce4aadfb62bc1308203 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 15:24:08 -0400 Subject: [PATCH 2/4] fix(launches): keep null pad and landing coordinates unknown instead of 0 Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ++++ src/data/rocketLaunches.test.mjs | 25 +++++++++++++++++++++++++ src/layers/launches/model.js | 17 ++++++++--------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee00d839..38c4f31ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Launch Library records with a null or blank pad latitude/longitude, landing + position, downrange distance or stage flight number now keep those values + unknown instead of reading them as 0, so a launch is no longer placed at + 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index a1a3fab44..ce2ad6c2a 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,6 +676,31 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); +test('null or blank pad and landing coordinates stay unknown instead of becoming 0', () => { + const base = { net: '2026-07-20T10:00:00Z' }; + for (const blank of [null, '', ' ', false]) { + assert.deepEqual( + normalizeRocketLaunches([{ ...base, id: 'blank', pad: { latitude: blank, longitude: blank } }], NOW), + [], + ); + } + // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. + const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback', + pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW); + assert.equal(fallback.lat, 28.6); + assert.equal(fallback.lon, -80.6); + // An undisclosed landing keeps position, distance and flight number unknown. + const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 }, + rocket: { launcher_stage: [{ launcher_flight_number: null, + landing: { attempt: true, downrange_distance: null, + landing_location: { latitude: null, longitude: null } } }] } }], NOW); + const [stage] = launch.recoveryStages; + assert.equal(stage.lat, null); + assert.equal(stage.lon, null); + assert.equal(stage.downrangeKm, null); + assert.equal(stage.flightNumber, null); +}); + test('normalizes detailed payload and stage recovery records', () => { const launches = normalizeRocketLaunches({ results: [{ id: 'recovery-details', diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 0a9dd67ea..5697e07e8 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -10,6 +10,9 @@ export const UNKNOWN_LAUNCH_STATUS = msg('Unknown'); export function createModel({ state: layerState, services, parts, source }) { function finiteCoordinate(value) { + // Number(null), Number('') and Number(false) are 0: absent is not zero. + if (value == null || typeof value === 'boolean') return null; + if (typeof value === 'string' && value.trim() === '') return null; const number = Number(value); return Number.isFinite(number) ? number : null; } @@ -184,15 +187,11 @@ export function createModel({ state: layerState, services, parts, source }) { const pad = launch.pad || {}; const location = pad.location || {}; const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates) - .split(',') - .map(Number); - const lat = Number.isFinite(Number(pad.latitude)) - ? Number(pad.latitude) - : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) - ? Number(pad.longitude) - : coordinateLon; + const [coordinateLon, coordinateLat] = String(coordinates).split(','); + const lat = + finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat); + const lon = + finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon); const payloads = normalizePayloadFlights(launch); return { id: String( From d3e11bfbcaa7656fdd7db5b607c152537b4cd4bd Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:19:12 -0400 Subject: [PATCH 3/4] test(launches): cover null top-level landing coordinates Signed-off-by: Rudy Celekli --- CHANGELOG.md | 4 ---- changelog.d/1186-launch-unknown-coordinates.md | 4 ++++ src/data/rocketLaunches.test.mjs | 14 ++++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1186-launch-unknown-coordinates.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c4f31ef..8ee00d839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,5 @@ # Changelog -- Launch Library records with a null or blank pad latitude/longitude, landing - position, downrange distance or stage flight number now keep those values - unknown instead of reading them as 0, so a launch is no longer placed at - 0°N 0°E and an undisclosed landing no longer reports a 0 km downrange. - Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both band edges, skips the starting station and rejects spectra from an earlier tune (Aditya, @Rohilalala, #1174). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md new file mode 100644 index 000000000..d03c2d902 --- /dev/null +++ b/changelog.d/1186-launch-unknown-coordinates.md @@ -0,0 +1,4 @@ +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index ce2ad6c2a..0d1eb8956 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -676,7 +676,7 @@ test('uses Launch Library 2 pad latitude/longitude fields', () => { assert.equal(launches[0].lon, -120.611); }); -test('null or blank pad and landing coordinates stay unknown instead of becoming 0', () => { +test('null or blank pad coordinates stay unknown instead of becoming 0', () => { const base = { net: '2026-07-20T10:00:00Z' }; for (const blank of [null, '', ' ', false]) { assert.deepEqual( @@ -684,15 +684,21 @@ test('null or blank pad and landing coordinates stay unknown instead of becoming [], ); } - // Null pad fields fall through to the location's "lon,lat" pair, not 0,0. +}); + +test('null pad coordinates use the location fallback instead of 0,0', () => { + const base = { net: '2026-07-20T10:00:00Z' }; const [fallback] = normalizeRocketLaunches([{ ...base, id: 'fallback', pad: { latitude: null, longitude: null, location: { coordinates: '-80.6,28.6' } } }], NOW); assert.equal(fallback.lat, 28.6); assert.equal(fallback.lon, -80.6); - // An undisclosed landing keeps position, distance and flight number unknown. +}); + +test('an undisclosed landing keeps top-level position and stage numbers unknown', () => { + const base = { net: '2026-07-20T10:00:00Z' }; const [launch] = normalizeRocketLaunches([{ ...base, id: 'landing', pad: { latitude: 1, longitude: 2 }, rocket: { launcher_stage: [{ launcher_flight_number: null, - landing: { attempt: true, downrange_distance: null, + landing: { attempt: true, latitude: null, longitude: null, downrange_distance: null, landing_location: { latitude: null, longitude: null } } }] } }], NOW); const [stage] = launch.recoveryStages; assert.equal(stage.lat, null); From 4690a5bea97f2969c77fb35219ceed9af7d02c48 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 16 Oct 2026 23:20:07 -0400 Subject: [PATCH 4/4] docs(launches): describe unknown-coordinate and flight labels Signed-off-by: Rudy Celekli --- docs/CURRENT-STATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..105fdd37f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -791,6 +791,11 @@ and `./layers/launches`. Each owns its catalog, render state, tracking, interact and teardown. Source adapters retain the existing CelesTrak and Launch Library endpoints; standalone entries provide scene services and the satellite dependency. The mission factory separates paths, replay, camera, panel, overlays and placement. +Launch Library pad coordinates retain unknown values: missing fields use the +reported location coordinates when available, and launches without a known pad +position stay off the map. Genuine zero coordinates remain valid. Undisclosed +stage coordinates, downrange distance and flight numbers remain unknown; the +mission panel omits the flight-number label when no number was reported. Pending mission requests abort on disable/destruction and malformed launch snapshots preserve the last accepted display. Catalog groups, propagation cadence, tracking intent, replay timing, controls and source attribution remain unchanged.