mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Reworks #568 to the shape asked for in review. Generalized from "AQHI" to a provider-backed Air quality layer, with ECCC's AQHI as the first registered provider. Readings are carried as { provider, scale, value, band } rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added without inventing a shared numeric meaning. Coverage is stated as "Canada only" on the provider, the layer row and the card. Adds the WEATHER panel readout (readout: true) carrying the band legend and the worst readings currently in view, with getRowControls/setRowControlsListener following the wind and cyclone cards. An unresolvable view rectangle degrades to the worst readings overall rather than to a blank card, and a rectangle crossing the antimeridian is handled. update() now takes the earthquakes layer's abort-and-guard shape: it aborts any in-flight request, passes the signal through to the source, and refuses to touch the scene when the request was superseded, aborted, or the layer was disabled while it was outstanding. disable() and destroy() abort too. Tests cover the late-response, superseded-refresh and disabled-update paths. Rebased on main: the source is registered in src/sources/reference.js, and the allocation profile was re-measured against the current worker — median 3,900 B/frame, max 4,114, 85.7 B/candidate, inside the shared 154 ceiling, budgeted at 5,200 for 26% headroom. The earlier figures no longer held. Freshness is now judged on the observation time rather than on when the last response arrived. lastUpdate only says the feed answered; a station that keeps returning a reading from four hours ago would otherwise be narrated as live by the HUD and the voice provenance envelope. getStats() reports observedAt and stale, so layerFeedState() calls the layer stale past two missed hourly publications, and degraded when a refresh fails over readings that are still good. The share-link token is 0, which is what layer-token:next allocates. Taking it moves the ledger fixtures that had hard-coded 0 as the free digit and 3 as the one after; those shift to 3 and 4. The pinned equality between the reservation ledger and LEGACY_LAYER_STATE_TOKENS also had to become "the closed legacy set plus air-quality", since that set is documented as never growing. Happy to re-pin this differently if you would rather the fixtures derive the provisional digits from nextLayerStateToken() so the next layer does not move them again. The layer and its source are registered as ./layers/air-quality and ./layers/air-quality/source, each with its own boundary group, following the perimeters pair. check:boundaries requires the package exports and the group exports to be the same set, so a layer that ships a source needs both.
172 lines
6.2 KiB
JavaScript
172 lines
6.2 KiB
JavaScript
/**
|
|
* qa-air-quality.mjs — headless proof for the Air quality layer.
|
|
*
|
|
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
|
|
* reach the app in the provider-shaped form other networks will share, and that
|
|
* the Weather readout descriptor carries a legend and an in-view list.
|
|
*
|
|
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
|
|
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
|
|
* with no reading surfacing as a confident 1 — is a real defect this catches.
|
|
*
|
|
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
|
|
*/
|
|
|
|
import puppeteer from 'puppeteer';
|
|
|
|
const argv = process.argv.slice(2);
|
|
const getOpt = (name, dflt) => {
|
|
const i = argv.indexOf(name);
|
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
|
};
|
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
|
|
|
const results = [];
|
|
function record(name, ok, detail) {
|
|
results.push({ name, ok, detail });
|
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function main() {
|
|
let exitCode = 0;
|
|
const consoleErrors = [];
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
args: [
|
|
'--use-gl=swiftshader',
|
|
'--enable-unsafe-swiftshader',
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
],
|
|
defaultViewport: { width: 1400, height: 900 },
|
|
});
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
|
|
page.on('console', (m) => {
|
|
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
|
|
});
|
|
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
|
{ timeout: 90000 },
|
|
);
|
|
await sleep(1500);
|
|
|
|
const stats = await page.evaluate(async () => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
await dm.setEnabled('air-quality', true);
|
|
const mod = dm.layers.get('air-quality')?.module;
|
|
if (!mod) return { missing: true };
|
|
let s = null;
|
|
for (let i = 0; i < 45; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
s = mod.getStats();
|
|
if (s.count > 0 || s.error) break;
|
|
}
|
|
return s;
|
|
});
|
|
|
|
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
|
|
record('stations report a current index', loaded,
|
|
`count=${stats?.count} error=${stats?.error}`);
|
|
if (!loaded) exitCode = 1;
|
|
|
|
const records = await page.evaluate(
|
|
() =>
|
|
window.__godsEyeView.dataManager
|
|
.layers.get('air-quality')
|
|
?.module.getAnalystRecords(500) || [],
|
|
);
|
|
|
|
// The provider-neutral contract other networks will share.
|
|
const shaped = records.every(
|
|
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
|
|
);
|
|
record('every reading carries provider, scale, value and band', shaped,
|
|
records.length
|
|
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
|
|
: 'no records');
|
|
if (!shaped) exitCode = 1;
|
|
|
|
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
|
|
record('every AQHI value is in the published integer form, floored at 1', published,
|
|
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
|
|
if (!published) exitCode = 1;
|
|
|
|
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
|
|
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
|
|
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
|
|
if (zones.length < 3) exitCode = 1;
|
|
|
|
// The card's whole point is "highest readings in view", so put the camera
|
|
// over Canada before asserting on it — the default view is over Austin,
|
|
// where an empty in-view list is correct and proves nothing.
|
|
await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
|
const d2r = Math.PI / 180;
|
|
try {
|
|
gev.viewer.camera.cancelFlight();
|
|
} catch {
|
|
/* no flight in progress */
|
|
}
|
|
gev.viewer.camera.setView({
|
|
destination: ellipsoid.cartographicToCartesian({
|
|
longitude: -100 * d2r,
|
|
latitude: 55 * d2r,
|
|
height: 4_000_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
gev.viewer.scene.requestRender?.();
|
|
});
|
|
await sleep(2500);
|
|
|
|
const controls = await page.evaluate(
|
|
() =>
|
|
window.__godsEyeView.dataManager
|
|
.layers.get('air-quality')
|
|
?.module.getRowControls?.() || null,
|
|
);
|
|
const readoutOk =
|
|
controls?.readout === true &&
|
|
Array.isArray(controls?.legend?.colors) &&
|
|
controls.legend.colors.length === controls.legend.labels.length &&
|
|
Array.isArray(controls?.list?.items) &&
|
|
controls.list.items.length > 0 &&
|
|
controls.list.items.every((item) => item.id && item.label && item.value) &&
|
|
/Canada only/.test(controls?.summary?.coverage || '');
|
|
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
|
|
controls
|
|
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
|
|
: 'no descriptor');
|
|
if (!readoutOk) exitCode = 1;
|
|
|
|
record('no console errors', consoleErrors.length === 0,
|
|
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
|
|
if (consoleErrors.length) exitCode = 1;
|
|
} catch (e) {
|
|
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
|
exitCode = 3;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const pass = results.filter((r) => r.ok === true).length;
|
|
const fail = results.filter((r) => r.ok === false).length;
|
|
console.log('\n' + '─'.repeat(60));
|
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
|
console.log('─'.repeat(60) + '\n');
|
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(3);
|
|
});
|