mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge main and narrow accessible input names to useful controls
This commit is contained in:
commit
3d526f208d
12 files changed
+312
-64
No files matched your search
+38
-14
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 })));
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
+19
-41
@@ -1,52 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('index.html: all interactive input elements have accessible names (WCAG 4.1.2)', () => {
|
||||
const htmlPath = path.resolve(process.cwd(), 'index.html');
|
||||
const html = readFileSync(htmlPath, 'utf8');
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
|
||||
|
||||
// Match all <input ... /> tags
|
||||
const inputMatches = [...html.matchAll(/<input\b([^>]*)\/?>/g)];
|
||||
assert.ok(inputMatches.length > 5, 'expected to find interactive input elements');
|
||||
|
||||
for (const match of inputMatches) {
|
||||
const attrs = match[1];
|
||||
const hasAriaLabel = /aria-label=["'][^"']+["']/.test(attrs);
|
||||
const hasAriaLabelledby = /aria-labelledby=["'][^"']+["']/.test(attrs);
|
||||
const idMatch = attrs.match(/\bid=["']([^"']+)["']/);
|
||||
const id = idMatch ? idMatch[1] : null;
|
||||
|
||||
let hasAssociatedLabel = false;
|
||||
if (id) {
|
||||
// Check if there is a <label for="id">
|
||||
const labelForRegex = new RegExp(`<label\\b[^>]*\\bfor=["']${id}["']`, 'i');
|
||||
hasAssociatedLabel = labelForRegex.test(html);
|
||||
}
|
||||
|
||||
const hasAccessibleName = hasAriaLabel || hasAriaLabelledby || hasAssociatedLabel;
|
||||
assert.ok(
|
||||
hasAccessibleName,
|
||||
`input tag "${match[0]}" must have an accessible name (aria-label, aria-labelledby, or label for)`
|
||||
);
|
||||
// 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('index.html: HUD sliders and controls have descriptive accessible labels', () => {
|
||||
const htmlPath = path.resolve(process.cwd(), 'index.html');
|
||||
const html = readFileSync(htmlPath, 'utf8');
|
||||
|
||||
assert.match(html, /id="scope-feather-slider"[^>]*aria-label="Scope edge feather"/);
|
||||
assert.match(html, /id="bloom-intensity-slider"[^>]*aria-label="Bloom intensity"/);
|
||||
assert.match(html, /id="sharpen-intensity-slider"[^>]*aria-label="Sharpen intensity"/);
|
||||
assert.match(html, /id="location-search"[^>]*aria-label="Search location by name or coordinates"/);
|
||||
assert.match(html, /data-first-run-suppress[^>]*aria-label="Do not show this message again on startup"/);
|
||||
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('ui.js: dynamic style parameter sliders set aria-label', () => {
|
||||
const uiPath = path.resolve(process.cwd(), 'src/ui.js');
|
||||
const uiJs = readFileSync(uiPath, 'utf8');
|
||||
|
||||
assert.match(uiJs, /slider\.setAttribute\(['"]aria-label['"],\s*uMeta\.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\)/);
|
||||
});
|
||||
Reference in new issue
Block a user