Merge main and narrow the change to Map Source focus and QA

This commit is contained in:
Sameh Khamis committed 2026-09-10 22:07:41 -07:00
commit 5dfef8e891
21 files changed
+340 -72

No files matched your search

+2
View File
@@ -138,3 +138,5 @@ AISSTREAM_API_KEY=
# CCTV_MAX_SOURCES=48 # overall cap on registered cameras
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
# CCTV_FORCE_AUSTIN= # force Austin-only sources
# CCTV_AUTO_CALIBRATE=1 # auto-calibrate camera projection (client flag)
# CCTV_DRAPE_MESH=1 # drape frames onto 3D geometry (client flag)
+17 -10
View File
@@ -1,5 +1,13 @@
# 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.
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).
@@ -7,16 +15,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Fixed
- `scripts/dev-cctv.sh` now binds to `localhost` by default, matching
`dev-fresh.sh` and `dev-secure.sh`. Set `HOST=0.0.0.0` to opt in to LAN
exposure. (#265)
- The Celestrak, terrain-heights, adsbdb, and launch-library proxies no longer
place raw exception text or upstream error bodies in HTTP responses; the real
error is logged server-side and the client gets a generic message. (#262, #263)
- `naturalEarthRegions.js`’s internal `haversineKm` takes `(lat, lon, lat, lon)`
like every sibling implementation instead of longitude-first. (#274)
- Dropped `CCTV_AUTO_CALIBRATE` and `CCTV_DRAPE_MESH` from `.env.example`; no
code reads them. (#270)
- Scope, Bloom, Sharpen, location search and generated style sliders expose
explicit accessible names. The first-run checkbox retains its native label.
- 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
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.
+27 -3
View File
@@ -1,7 +1,29 @@
# 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.
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
## Control names for assistive technology
Scope, Bloom, Sharpen and location search have explicit accessible names.
Generated style sliders use the same name as their visible parameter label.
The first-run suppression checkbox keeps its native wrapping label, so its
accessible name remains "Don't show this again". Control behavior is unchanged.
## 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
@@ -1684,9 +1706,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
+4 -4
View File
@@ -440,7 +440,7 @@
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" title="Scope edge feather as a percentage of the keyhole radius" />
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
@@ -460,7 +460,7 @@
<span class="pp-label">Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" />
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
@@ -470,7 +470,7 @@
<span class="pp-label">Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" />
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
@@ -565,7 +565,7 @@
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" />
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
</div>
</div>
</div>
+1 -2
View File
@@ -5,8 +5,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PORT="${PORT:-4173}"
# Local-only by default; set HOST=0.0.0.0 explicitly to expose on the LAN.
HOST="${HOST:-localhost}"
HOST="${HOST:-0.0.0.0}"
CCTV_SOURCES_FILE="${CCTV_SOURCES_FILE:-config/cctv_sources.austin.json}"
CCTV_PREFER_AUSTIN="${CCTV_PREFER_AUSTIN:-1}"
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-36}"
+1 -3
View File
@@ -222,9 +222,7 @@ async function main() {
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
// Metal is a macOS-only ANGLE backend; off-Mac the GPU branch has to fall
// back to the software rasterizer or WebGL init fails outright.
...(SWIFTSHADER || process.platform !== 'darwin'
...(SWIFTSHADER
? ['--use-gl=angle', '--use-angle=swiftshader']
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
'--disable-dev-shm-usage',
+1 -7
View File
@@ -20,13 +20,7 @@ const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candid
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
});
const page = await browser.newPage();
const failures = [];
+1 -4
View File
@@ -491,10 +491,7 @@ async function main() {
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox',
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist',
'--disable-dev-shm-usage', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--window-size=1440,900',
],
+1 -2
View File
@@ -37,8 +37,7 @@ import path from 'node:path';
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const argv = Object.fromEntries(process.argv.slice(2)
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
// Metal is a macOS-only ANGLE backend; defaulting to it off-Mac fails WebGL init.
const ANGLE = String(argv.angle || (process.platform === 'darwin' ? 'metal' : 'swiftshader'));
const ANGLE = String(argv.angle || 'metal');
const HEADFUL = !!argv.headful;
// Austin airport apron by default — the site qa-floor-verify pins, where the
// mesh sits ~150 m above the geoid and burial is unmistakable.
+1 -7
View File
@@ -39,13 +39,7 @@ const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candid
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
});
const page = await browser.newPage();
const failures = [];
+38 -14
View File
@@ -121,6 +121,35 @@ 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
|| 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;
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 +201,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 +220,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 +238,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 +253,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,
+63
View File
@@ -338,3 +338,66 @@ 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 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] } },
{ ...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);
}
});
+80
View File
@@ -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 })));
});
+2 -3
View File
@@ -34,8 +34,7 @@ function ringAreaKm2(ring) {
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
/** Great-circle distance in km. Arg order matches every sibling: (lat, lon, lat, lon). */
function haversineKm(lat1, lon1, lat2, lon2) {
function haversineKm(lon1, lat1, lon2, lat2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const h = Math.sin(dLat / 2) ** 2
@@ -148,7 +147,7 @@ function buildEntries(pack, kind) {
polygons,
areaKm2,
bbox: [minLon, minLat, maxLon, maxLat],
bboxDiagonalKm: haversineKm(minLat, minLon, maxLat, maxLon),
bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat),
});
}
return out;
+16
View File
@@ -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) {
+31
View File
@@ -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',
+5 -3
View File
@@ -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;
+13
View File
@@ -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, []);
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium.
// Native labels and hidden inputs must not be treated as missing aria-labels.
test('HUD sliders and location search have descriptive explicit names', () => {
for (const [id, name] of [
['scope-feather-slider', 'Scope edge feather'],
['bloom-intensity-slider', 'Bloom intensity'],
['sharpen-intensity-slider', 'Sharpen intensity'],
['location-search', 'Search location by name or coordinates'],
]) {
const input = html.match(new RegExp(`<input\\b[^>]*\\bid="${id}"[^>]*>`))?.[0];
assert.ok(input, `${id} exists`);
assert.ok(input.includes(`aria-label="${name}"`), `${id} has its descriptive name`);
}
});
test('the first-run checkbox keeps its native visible label', () => {
assert.match(html, /<label\b[^>]*class="first-run-suppress"[^>]*>\s*<input type="checkbox" data-first-run-suppress \/>\s*<span>Don't show this again<\/span>\s*<\/label>/);
});
test('generated style sliders use the visible parameter label as their name', () => {
assert.match(ui, /label\.textContent\s*=\s*uMeta\.label;/);
assert.match(ui, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.label\)/);
});
+1
View File
@@ -8948,6 +8948,7 @@ export class StyleManager {
const slider = document.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', uMeta.label);
slider.min = uMeta.min;
slider.max = uMeta.max;
slider.step = uMeta.max <= 1 ? '0.01' : '0.1';
+5 -10
View File
@@ -1645,8 +1645,7 @@ function celestrakProxy() {
send(502, 'celestrak fetch failed and no cache available', 'NONE');
}
} catch (err) {
console.error('[celestrak-proxy]', err?.message || String(err));
send(500, 'celestrak proxy error', 'ERROR');
send(500, `celestrak proxy error: ${err?.message || err}`, 'ERROR');
}
});
},
@@ -1756,12 +1755,10 @@ function rocketLaunchesProxy() {
send(res, 200, stale.body, 'STALE-ERROR');
return;
}
// Upstream error bodies stay server-side; the client gets a generic message.
if (error?.upstreamBody) console.warn('[launch-library-proxy] upstream error body:', error.upstreamBody);
send(
res,
Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502,
JSON.stringify({ error: 'Launch Library 2 unavailable' }),
error?.upstreamBody || JSON.stringify({ error: 'Launch Library 2 unavailable' }),
'NONE',
);
}
@@ -2097,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 });
@@ -2376,8 +2373,7 @@ function terrainHeightsProxy() {
}
send(outcome.status, outcome.body);
} catch (err) {
console.error('[terrain-heights-proxy]', err?.message || String(err));
send(500, { error: 'terrain heights proxy error' });
send(500, { error: `terrain heights proxy error: ${err?.message || err}` });
}
});
},
@@ -2498,8 +2494,7 @@ function adsbdbProxy() {
}
return send(404, { error: 'unknown endpoint' });
} catch (err) {
console.error('[adsbdb-proxy]', err?.message || String(err));
return send(500, { error: 'adsbdb proxy error' });
return send(500, { error: String(err?.message || err) });
}
});
},