gods-eye-view/scripts/qa-height-datum.mjs

728 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* qa-height-datum.mjs — height/vertical-datum fix numeric proof harness
* (docs/plans/2026-07-05-entity-height-datum-fix.md Task 8).
*
* Scaffold reused verbatim from qa-cctv-v2.mjs: the puppeteer launcher
* (Chrome executable discovery, headless flags), `QA_BASE_URL` env,
* `record()`/tally pattern, and the Google-Maps-key-injection dev-server
* recipe documented in its header comment. See that file for the reasoning
* behind the launch flags and the SwiftShader caveats they share.
*
* Unlike qa-cctv-v2 (frustum GEOMETRY / raycast-count invariants), this
* harness asserts NUMBERS: ground heights, altitudes, and terrain-provider
* identity. Every assertion is GL-independent (no pixel/screenshot
* comparisons) — it reads plain numbers off live Cesium entities and the
* app's own public module APIs, exactly like qa-cctv-v2's SERIALIZE_GEOM_SRC
* pattern.
*
* Three assertion groups (brief order):
*
* 1. CCTV ground correctness — for 5 London + 5 Austin + 3 SF cameras,
* assert each record's placed mount ground reads finitely, then compare
* its separately exposed `groundPriorM` against the
* authoritative Re:Earth ellipsoidal value fetched through the app's
* OWN `/api/terrain/heights` proxy (not a second upstream call — the
* proxy IS the oracle here, matching Task 2/3's contract). Keeping the
* prior separate is essential because Google-3D can legitimately refine
* the rendered ground to the photogrammetric mesh while the immutable
* Re:Earth prior remains the fallback/datum reference. The prior check is
* `|groundPriorM - reearthEllipsoid| < 6m`. Google-3D after
* the one-shot snap: London/Austin active-camera ground must fall in
* the brief's bands (tileset-vs-DEM legitimately differs by building
* height, so bands not exact-match).
*
* 2. Aircraft — sample >=10 live aircraft via the flights layer's public
* API. `renderAltitudeM` isn't exposed as a named field on any public
* method (only `flights.js`/`militaryFlights.js` module-private
* `_flightData` carries `geoAltitudeM`/`renderAltitudeM` directly), so
* this harness reconstructs both sides purely from public methods:
* - render height: `getAllPositions()[i].altitudeM`, which is
* `Cartographic.fromCartesian(billboard.position).height` — i.e.
* exactly the height passed to `Cartesian3.fromDegrees(lon, lat,
* renderAltitudeM)` at the placement site (flights.js ~:2100).
* - aviation/baro height: `getNearby()`/`findByQuery()`'s
* `altitudeM`, which is `info.altitude` (the UNTOUCHED sticky
* barometric field — flights.js ~:2111) — this is the "geoAltM ??
* baroAltM" input side, not the render output.
* This is the brief's documented fallback ("if geo/baro fields aren't
* exposed on the public state, assert the weaker but still-meaningful
* under-terrain + finite/plausible checks") PLUS a same-process,
* Node-side independent recomputation of `baroM + geoidHeight(lat,lon)`
* via a direct import of `src/data/geoid.js` (a pure module, imported
* the same way qa-cctv-v2.mjs imports `computeFrustumGeometry` from
* cctv.js) — giving a real numeric cross-check without reaching into
* the layer's private closure state.
*
* Under-terrain detector fingerprint (tightened): a real datum-miss
* renders an aircraft at the ~0m ellipsoidal SENTINEL (the pre-existing
* "no data yet" default) while its actual terrain ground is nowhere
* near 0 — that combination is unambiguous. A non-zero under-terrain
* reading is NOT hard-failed: a legitimately climbing/descending
* aircraft can transiently render close to real ground near an airport
* (e.g. a climb-out a few dozen metres above a field that itself sits
* well above sea level), which is correct behavior, not a bug. Those
* ambiguous cases are recorded INCONCLUSIVE with the raw numbers
* rather than FAILed.
*
* 3. Regime C re-resolve — switch the map stack to keyless OSM in-page via
* `mapStackController.setStack('osm')`, assert
* `viewer.terrainProvider` is NOT an `EllipsoidTerrainProvider`
* (constructor-name check, matching qa-cctv-v2's "borrow statics off a
* live instance's constructor" pattern — no `window.Cesium` global
* exists), and that CCTV grounds re-resolve within one event cycle
* (`gev:map-stack-changed` → cctv.js's regime re-arm — see its Task 5
* handler) by serializing a camera's ground height before/after the
* switch settles and asserting it moved OFF any old fabricated-prior
* value.
*
* Upstream-dependent assertions (live Re:Earth `/api/terrain/heights`, live
* OpenSky aircraft) are marked INCONCLUSIVE — not FAIL — when the upstream
* is unreachable this run, mirroring qa-cctv-v2's tiles-timeout handling.
* GL-dependent tile settling (the Google-3D one-shot ground snap actually
* completing under headless SwiftShader) is likewise inconclusive-on-timeout.
*
* Run: QA_BASE_URL=http://localhost:4300 node scripts/qa-height-datum.mjs
*
* Exit 0 = no hard failures. Non-zero = at least one hard FAIL (or harness error).
*/
import puppeteer from 'puppeteer';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
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 getFlag = (name) => argv.includes(name);
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const APP_URL = getOpt('--url', BASE_URL);
const HEADFUL = getFlag('--headful');
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'height-datum-qa');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
/* ignore */
}
}
return null;
}
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? `${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Waits for the scene's 3D tileset to report tilesLoaded===true. Copied from
* qa-cctv-v2.mjs (see its header comment for why this must be re-awaited
* fresh before each action that depends on a REAL ground sample, not
* reused from an earlier wait).
* @param {import('puppeteer').Page} page
* @param {number} [timeoutMs=15000]
* @returns {Promise<boolean>}
*/
function waitForTilesLoaded(page, timeoutMs = 15000) {
return page.waitForFunction(
() => {
const scene = window.__godsEyeView.viewer.scene;
const prims = scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
}
return true;
},
{ timeout: timeoutMs }
).then(() => true).catch(() => false);
}
/**
* Reads a camera's placed mount-point cartographic height off its
* `cctv-<id>-ray-tl` polyline entity (first position = the mount, per
* cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns
* `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the
* SAME arithmetic cctv.js itself uses in reverse
* (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`).
* @param {import('puppeteer').Page} page
* @param {string} camId
* @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>}
*/
async function readCameraGround(page, camId) {
return page.evaluate((id) => {
const viewer = window.__godsEyeView.viewer;
const time = viewer.clock.currentTime;
const ent = viewer.entities.getById(`cctv-${id}-ray-tl`);
if (!ent || !ent.polyline) return null;
const positions = ent.polyline.positions.getValue(time);
if (!Array.isArray(positions) || positions.length < 1) return null;
const mount = positions[0];
// No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's
// static fromCartesian off a live Cartographic instance's constructor.
const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount);
const mountAltM = carto.height;
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
const cam = mod.getUIState().cameras.find((c) => c.id === id);
const mountHeightM = cam ? cam.mountHeightM : null;
return {
mountAltM,
mountHeightM,
groundM: Number.isFinite(mountHeightM) ? mountAltM - mountHeightM : null,
};
}, camId);
}
/**
* Fetches the authoritative Re:Earth ellipsoidal ground height for a
* (lat, lon) pair through the APP'S OWN `/api/terrain/heights` proxy (the
* same oracle terrainHeights.js's `resolveEllipsoidalGround` uses) — this is
* the ground-truth reference for assertion group 1, fetched directly (not
* through in-page code) so a proxy hiccup can be told apart from an app bug.
* @param {number} lat
* @param {number} lon
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
*/
async function fetchReearthEllipsoid(lat, lon) {
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) return null;
const body = await res.json();
const v = Number(body?.results?.[0]?.ellipsoid);
return Number.isFinite(v) ? v : null;
} catch {
return null;
}
}
async function main() {
console.log(`\nHeight-Datum Fix Numeric Proof`);
console.log(` App URL : ${APP_URL}`);
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
try {
const res = await fetch(APP_URL, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
// Node-side geoid grid, used only for the aircraft baro+N cross-check
// (independent of the app's own bundled copy — same package, separate
// process, so this is a real recomputation, not reading the app's cache).
await ensureGeoidReady();
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
});
const consoleErrors = [];
let exitCode = 0;
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
page.on('console', (msg) => {
if (msg.type() === 'error') {
const t = msg.text();
if (!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(t)) {
consoleErrors.push(t);
}
}
});
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
{ timeout: 60000 }
);
await sleep(4000);
// =========================================================================
// Group 1: CCTV ground correctness
// =========================================================================
console.log('Enabling CCTV layer (full city-packs catalog)...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const entry = dm.layers.get('cctv');
if (!entry.enabled) await dm.toggle('cctv');
});
const camCount = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().count);
console.log(`CCTV catalog loaded: ${camCount} cameras. Waiting for the geometry-load queue to drain...`);
// The full-catalog drain is NOT a precondition for group 1's assertions
// below: every record renders correctly from `record.groundPrior` the
// instant the Re:Earth prior batch lands (Task 5 contract #1/#4 — "the
// prior IS the resolution" on globe stacks, and the fallback path in
// google-3d before a real sample resolves), which is exactly what makes
// the per-camera ground reads meaningful even mid-drain. The queue drain
// itself additionally attempts ONE REAL scene.sampleHeight per camera in
// google-3d regime (Task 5 contract #3/#5 — the ≤1×N invariant
// qa-cctv-v2 also locks), and at this branch's full city-packs catalog
// size (800 cameras: 250 Austin + 300 Caltrans + 250 TfL — measured
// directly probing this harness's own dev server) that can take many
// minutes under headless SwiftShader (empirically ~2-6s/sample once
// real tile contention kicks in past the first few hundred). Waiting for
// FULL completion here would make routine harness runs impractically
// slow for no assertion gain, so this bounds the wait to a fixed,
// reasonable ceiling and reports the outcome as environmental/scale
// dependent (inconclusive, not a hard fail) rather than failing the
// whole run — mirroring qa-cctv-v2's own tiles-timeout handling.
const DRAIN_WAIT_CEILING_MS = 120000;
const drained = await page.waitForFunction(
() => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
const ui = mod.getUIState();
return ui.loading && ui.loading.active === false;
},
{ timeout: DRAIN_WAIT_CEILING_MS }
).then(() => true).catch(() => false);
record(`CCTV geometry-load queue drains within ${Math.round(DRAIN_WAIT_CEILING_MS / 1000)}s (N=${camCount})`,
drained ? true : null,
drained
? 'loading.active === false'
: `did not fully drain within the ceiling at N=${camCount} — environmental/scale-dependent under headless SwiftShader ` +
'(each un-drained camera still renders correctly from its Re:Earth prior; group 1 assertions below read that prior directly, so this does not block them)');
// Pull the full camera list, then bucket by city so we can pick 5
// London + 5 Austin + 3 SF regardless of catalog ordering. TfL tags
// `city: 'London'` and Austin's own pack tags `city: 'Austin'` exactly
// (vite.config.js); Caltrans tags `city` with an arbitrary upstream
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
// Caltrans cameras are identified by proximity to the SF anchor instead
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
console.log(`Camera catalog: ${allCameras.length} total.`);
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
const haversineKm = (lat1, lon1, lat2, lon2) => {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
const sfCams = allCameras
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
.slice(0, 3);
record('found >=5 London (TfL) test cameras', londonCams.length >= 5, `found ${londonCams.length}`);
record('found >=5 Austin test cameras', austinCams.length >= 5, `found ${austinCams.length}`);
record('found >=3 SF-area (Caltrans) test cameras', sfCams.length >= 3 ? true : null,
sfCams.length >= 3
? `found ${sfCams.length}`
: `found ${sfCams.length} — live Caltrans catalog unavailable/partial this run (upstream INCONCLUSIVE, not an app datum failure)`);
const cityBuckets = [
{ label: 'London', cams: londonCams },
{ label: 'Austin', cams: austinCams },
{ label: 'SF', cams: sfCams },
];
const groundRows = []; // for the report/console table
for (const { label, cams } of cityBuckets) {
for (const cam of cams) {
const state = await page.evaluate((id) => {
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
return mod.getUIState().cameras.find((camera) => camera.id === id) || null;
}, cam.id);
const geom = await readCameraGround(page, cam.id);
if (!geom || !Number.isFinite(geom.groundM)) {
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height');
continue;
}
record(`${label} ${cam.id}: rendered ground reads finitely`, true,
`renderedGround=${geom.groundM.toFixed(1)}m`);
const priorM = state?.groundPriorM;
if (!Number.isFinite(priorM)) {
record(`${label} ${cam.id}: Re:Earth ground prior resolved`, null,
`renderedGround=${geom.groundM.toFixed(1)}m; prior unavailable this run`);
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM: null, reearthM: null });
continue;
}
const reearth = await fetchReearthEllipsoid(cam.lat, cam.lon);
if (reearth === null) {
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, null,
`Re:Earth proxy unreachable this run — prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m (upstream INCONCLUSIVE, not a harness/app failure)`);
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: null });
continue;
}
const delta = Math.abs(priorM - reearth);
record(`${label} ${cam.id}: |ground prior - Re:Earth ellipsoid| < 6m`, delta < 6,
`prior=${priorM.toFixed(1)}m rendered=${geom.groundM.toFixed(1)}m reearth=${reearth.toFixed(1)}m Δ=${delta.toFixed(1)}m`);
groundRows.push({ label, id: cam.id, groundM: geom.groundM, priorM, reearthM: reearth });
}
}
console.log('\n Ground-height table (city, camera, rendered ground, stored prior, Re:Earth ellipsoid):');
for (const row of groundRows) {
console.log(` ${row.label.padEnd(8)} ${row.id.padEnd(24)} rendered=${row.groundM.toFixed(1)}m` +
(row.priorM !== null ? ` prior=${row.priorM.toFixed(1)}m` : ' prior=N/A') +
(row.reearthM !== null ? ` reearth=${row.reearthM.toFixed(1)}m` : ' reearth=N/A'));
}
console.log('');
// -----------------------------------------------------------------------
// Google-3D band check: activate the London and Austin cameras nearest
// the viewer (mirrors qa-cctv-v2's focusNearest activation pattern),
// wait for the one-shot ground snap to complete (tilesLoaded gate), then
// assert the ACTIVE camera's ground falls in the brief's band. Bands
// (not exact) because the Google tileset surface legitimately differs
// from the Re:Earth DEM by building height once the snap refines it.
// -----------------------------------------------------------------------
console.log('Checking Google-3D active-camera ground BANDS (post-snap)...');
async function checkActiveCameraBand(cityLabel, targetCamId, band) {
await page.evaluate((id) => {
window.__godsEyeView.dataManager.layers.get('cctv').module.selectCamera(id, { focus: true, durationSec: 0.1 });
}, targetCamId);
await sleep(600);
const tilesReady = await waitForTilesLoaded(page, 30000);
// Give the one-shot completion pass a beat to land even after tiles
// report ready (update() ticks on its own interval — see cctv.js).
await sleep(1500);
const geom = await readCameraGround(page, targetCamId);
if (!tilesReady) {
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, null,
`tiles never reported loaded within 30s under headless GL — environmental, inconclusive (recordGround=${geom?.groundM?.toFixed?.(1) ?? 'n/a'}m)`);
return;
}
if (!geom || !Number.isFinite(geom.groundM)) {
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, false, 'ground unreadable');
return;
}
const inBand = geom.groundM >= band[0] && geom.groundM <= band[1];
record(`${cityLabel} active camera (${targetCamId}) google-3d ground ∈ [${band[0]},${band[1]}]m`, inBand,
`recordGround=${geom.groundM.toFixed(1)}m`);
}
if (londonCams.length) {
await checkActiveCameraBand('London', londonCams[0].id, [45, 75]);
} else {
record('London active camera google-3d ground ∈ [45,75]m', null, 'no London camera available to test');
}
if (austinCams.length) {
await checkActiveCameraBand('Austin', austinCams[0].id, [110, 135]);
} else {
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
}
// =========================================================================
// Group 2: Aircraft render altitude
// =========================================================================
console.log('Enabling flights layer, waiting for a live aircraft sample...');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const entry = dm.layers.get('flights');
if (!entry.enabled) await dm.toggle('flights');
});
// OpenSky polls on its own interval; give it real time to land a batch
// (the layer polls every ~30s per the documented polling invariant).
const gotAircraft = await page.waitForFunction(
() => {
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
return mod.getAllPositions(1).length > 0;
},
{ timeout: 45000 }
).then(() => true).catch(() => false);
if (!gotAircraft) {
record('>=10 live aircraft sampled from OpenSky', null,
'no aircraft appeared within 45s — OpenSky upstream INCONCLUSIVE this run (not an app failure)');
} else {
// Let a full second poll cycle land (updateInterval: 30000 — see
// flights.js) before sampling. A just-appeared ON-GROUND aircraft can
// legitimately render at the sticky `alt` default (0m ellipsoidal) for
// ONE tick while `_warmGroundedAircraftSurfaceCache`'s batch resolve is
// still in flight (fire-and-forget; picked up by cachedEllipsoidalGround
// on a LATER poll — see flights.js's own comment on that function). That
// window is a documented, time-bounded product behavior, not a
// datum-fix regression — sampling immediately after the FIRST aircraft
// appears would catch exactly that transient and misreport it as an
// under-terrain violation. Waiting past a second poll interval lets the
// cache warm before the under-terrain detector runs.
console.log('Waiting through a second poll cycle so on-ground aircraft ground-cache warm-up completes...');
await sleep(32000);
// getAllPositions gives render-height (carto.height off the actual
// billboard Cartesian3 — i.e. renderAltitudeM as placed by
// Cartesian3.fromDegrees(lon, lat, renderAltitudeM)); getNearby gives
// the untouched aviation/baro field (info.altitude) per-icao24. Cross
// the two public APIs by icao24/id to reconstruct both sides without
// reaching into the layer's private _flightData closure.
const allPositions = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
return mod.getAllPositions(60);
});
// getNearby needs an ECEF center + range; use the live camera position
// (a real Cartesian3 instance — no window.Cesium global available, so
// this borrows a live instance rather than constructing one, matching
// qa-cctv-v2's pattern) with an effectively unbounded range so it
// returns everything currently shown regardless of viewer position.
const nearbyList = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
const center = window.__godsEyeView.viewer.camera.position;
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
});
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
const sampleSize = Math.min(allPositions.length, 25);
const sample = allPositions.slice(0, sampleSize);
record(`>=10 live aircraft sampled from OpenSky (public getAllPositions/getNearby)`,
sample.length >= 10 ? true : (sample.length > 0 ? null : false),
`sampled ${sample.length} (getAllPositions total=${allPositions.length})`);
let plausibleCount = 0;
let checkedCount = 0;
const underTerrainDetails = [];
const baroPlusNDeltas = [];
for (const ac of sample) {
const renderAltM = ac.altitudeM; // carto.height off the live billboard position
const baroM = baroById.get(ac.id);
// Finite + plausible sanity: render altitude must be a real number in
// a physically sane band (-500m .. 20000m covers everything from
// Death-Valley-adjacent ground traffic to high-altitude cruise).
const finitePlausible = Number.isFinite(renderAltM) && renderAltM > -500 && renderAltM < 20000;
if (finitePlausible) plausibleCount += 1;
// Cross-check vs baro+geoidHeight when we have the baro side (weaker
// per-aircraft equality check: geoAltitudeM isn't exposed as a named
// field on any public flights-layer method — only the module-private
// `_flightData` carries geoAltitudeM/renderAltitudeM directly — so
// this harness can only recompute the FALLBACK branch's expected
// value (baro + geoid N) from the public API and log the delta,
// rather than assert it individually, for aircraft where the true
// render source was actually geo_altitude, which can legitimately
// differ from baro+N by tens of metres).
if (Number.isFinite(baroM) && Number.isFinite(renderAltM)) {
const n = geoidHeight(ac.latitude, ac.longitude);
const expectedBaroPlusN = baroM + n;
const deltaVsBaroPlusN = Math.abs(renderAltM - expectedBaroPlusN);
// Not asserted individually (geo_altitude legitimately diverges from
// baro+N) — logged into the report table so the cross-check is
// actually visible, not silently discarded.
baroPlusNDeltas.push({ id: ac.id, renderAltM, expectedBaroPlusN, deltaVsBaroPlusN });
}
checkedCount += 1;
}
record(`aircraft render altitudes are finite + physically plausible (-500..20000m)`,
checkedCount > 0 ? plausibleCount === checkedCount : null,
`${plausibleCount}/${checkedCount} plausible`);
if (baroPlusNDeltas.length) {
console.log('\n baro+geoidN cross-check table (id, renderAlt, expected baro+N, |Δ|):');
for (const d of baroPlusNDeltas) {
console.log(` ${String(d.id).padEnd(10)} renderAlt=${d.renderAltM.toFixed(1)}m` +
` expected=${d.expectedBaroPlusN.toFixed(1)}m Δ=${d.deltaVsBaroPlusN.toFixed(1)}m`);
}
console.log('');
}
// Under-terrain detector, tightened to its unambiguous fingerprint.
// Ground truth is fetched fresh through the app's own
// /api/terrain/heights proxy (same oracle as group 1), so this stays
// GL-independent and upstream-verifiable rather than reaching into
// private closures.
//
// The REAL bug (fixed by this task's Bug 1 change: flights.js's
// _warmGroundedAircraftSurfaceCache now keys its warm-cache resolve off
// the aircraft's RAW POLL lat/lon — the same coords
// pickRenderAltitudeM's surfaceM read uses — instead of the
// continuously dead-reckoned billboard position) has an unambiguous
// numeric fingerprint: an aircraft rendering at the ~0m ellipsoidal
// SENTINEL (the pre-existing "no geo/baro data yet" default) while its
// terrain ground is nowhere near 0m. HARD-FAIL only on that exact
// combination.
//
// Any OTHER under-terrain reading (non-zero renderAltitudeM below
// ground - 50m) is ambiguous — it could legitimately be a climbing/
// descending aircraft briefly near real ground elevation close to an
// airport (e.g. a departure a few dozen metres up over a field that
// itself sits well above sea level) — so those are recorded
// INCONCLUSIVE with the raw values, not FAILed.
const SENTINEL_EPS_M = 1; // Math.abs(renderAlt) < 1 is the exact 0m default
const SENTINEL_GROUND_FLOOR_M = 100; // terrain must be unambiguously non-zero
let underTerrainChecked = 0;
let sentinelViolations = 0;
let ambiguousUnderTerrain = 0;
const ambiguousDetails = [];
for (const gc of sample) {
const ground = await fetchReearthEllipsoid(gc.latitude, gc.longitude);
if (ground === null) continue; // upstream unreachable for this point — skip, don't fail
underTerrainChecked += 1;
if (gc.altitudeM >= ground - 50) continue; // not under terrain at all
const isSentinelMiss = Math.abs(gc.altitudeM) < SENTINEL_EPS_M && Math.abs(ground) > SENTINEL_GROUND_FLOOR_M;
if (isSentinelMiss) {
sentinelViolations += 1;
underTerrainDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m (0m SENTINEL fingerprint)`);
} else {
ambiguousUnderTerrain += 1;
ambiguousDetails.push(`${gc.id}: renderAlt=${gc.altitudeM.toFixed(1)}m ground=${ground.toFixed(1)}m`);
}
}
record('no aircraft renders at the 0m sentinel while terrain ground is far from 0m (datum-miss fingerprint)',
underTerrainChecked > 0 ? sentinelViolations === 0 : null,
underTerrainChecked > 0
? `${sentinelViolations} violation(s) out of ${underTerrainChecked} checked${underTerrainDetails.length ? ': ' + underTerrainDetails.slice(0, 3).join(' | ') : ''}`
: 'Re:Earth proxy unreachable for every sampled point this run — INCONCLUSIVE');
if (ambiguousUnderTerrain > 0) {
record('other under-terrain readings (non-zero, ambiguous — may be legit climb/descent near an airport)',
null,
`${ambiguousUnderTerrain} case(s): ${ambiguousDetails.slice(0, 5).join(' | ')}`);
}
}
// =========================================================================
// Group 3: Regime C re-resolve
// =========================================================================
console.log('Switching map stack to keyless OSM (regime C)...');
// Grab a London camera's ground BEFORE the switch (still whatever regime
// we were in — Google-3D, given the app's default).
const regimeCTargetId = londonCams[0]?.id || austinCams[0]?.id || allCameras[0]?.id;
const groundBeforeSwitch = regimeCTargetId ? await readCameraGround(page, regimeCTargetId) : null;
await page.evaluate(async () => {
await window.__godsEyeView.mapStackController.setStack('osm');
});
// Real (non-flat) terrain loads ASYNCHRONOUSLY on a globe stack, and by two
// different code paths depending on whether a Cesium Ion token is present:
// - NO token → keyless Re:Earth `CesiumTerrainProvider` via a DIRECT
// assignment (synchronous — ready immediately).
// - token → Cesium World Terrain via `scene.setTerrain(...)`, whose
// provider resolves in the background: `viewer.terrainProvider`
// is transiently `null` until the World Terrain layer.json
// loads (can exceed 1.5 s under SwiftShader), THEN becomes a
// `CesiumTerrainProvider`.
// Either way the END STATE is a real `CesiumTerrainProvider`, never the flat
// `EllipsoidTerrainProvider` regime C used to be stuck on. Poll for that end
// state instead of a fixed sleep (a fixed sleep races the async World Terrain
// load — the old failure mode). Report whatever it settled on.
let terrainCtor = null;
try {
await page.waitForFunction(() => {
const p = window.__godsEyeView?.viewer?.terrainProvider;
const name = p && p.constructor && p.constructor.name;
return !!name && name !== 'EllipsoidTerrainProvider';
}, { timeout: 12000, polling: 250 });
} catch { /* timed out — fall through and report the settled ctor below */ }
terrainCtor = await page.evaluate(() => {
const provider = window.__godsEyeView.viewer.terrainProvider;
return provider?.constructor?.name || null;
});
record('globe-stack terrain is a real CesiumTerrainProvider, not the flat EllipsoidTerrainProvider (regime B/C)',
terrainCtor !== null && terrainCtor !== 'EllipsoidTerrainProvider',
`constructor=${terrainCtor}`);
if (regimeCTargetId) {
const groundAfterSwitch = await readCameraGround(page, regimeCTargetId);
if (!groundBeforeSwitch || !groundAfterSwitch ||
!Number.isFinite(groundBeforeSwitch.groundM) || !Number.isFinite(groundAfterSwitch.groundM)) {
record(`CCTV grounds re-resolved for regime C (${regimeCTargetId})`, false, 'ground unreadable before or after switch');
} else {
// The prior-everywhere invariant (Task 5) means BOTH readings should
// already be close to the Re:Earth ellipsoid value in every regime —
// the real regression this guards is the OLD fabricated-prior bug
// (London ~15m, Austin ~150m fixed catalog numbers regardless of
// regime). Assert the post-switch ground is NOT stuck at either
// known-bad fabricated constant, and is within a generous tolerance
// of the pre-switch value (both should already reflect the same
// Re:Earth prior — a big jump would indicate a stale/blank regime
// record instead of a re-arm).
const isLondonId = londonCams.some((c) => c.id === regimeCTargetId);
const staleValue = isLondonId ? 15 : 150;
const notStuckAtFabricated = Math.abs(groundAfterSwitch.groundM - staleValue) > 5;
const stableAcrossSwitch = Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM) < 20;
record(`CCTV ground NOT stuck at the old fabricated prior (${staleValue}m) after regime C switch (${regimeCTargetId})`,
notStuckAtFabricated,
`before=${groundBeforeSwitch.groundM.toFixed(1)}m after=${groundAfterSwitch.groundM.toFixed(1)}m`);
record(`CCTV ground re-resolved within one event cycle, consistent with the prior-everywhere invariant (${regimeCTargetId})`,
stableAcrossSwitch,
`Δ=${Math.abs(groundAfterSwitch.groundM - groundBeforeSwitch.groundM).toFixed(1)}m`);
}
} else {
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
}
// -----------------------------------------------------------------------
// Screenshot for human visual review (not a pass/fail gate).
// -----------------------------------------------------------------------
await sleep(500);
const shot = path.join(SHOTS_DIR, 'height-datum-regimeC.png');
await page.screenshot({ path: shot });
console.log(` screenshot (visual review only, not a gate) → ${path.relative(REPO_ROOT, shot)}`);
record('no console errors during height-datum exercise', consoleErrors.length === 0,
consoleErrors.length ? consoleErrors.slice(0, 3).join(' | ') : 'clean');
for (const r of results) {
if (r.ok === false) exitCode = 1;
}
} finally {
await browser.close();
}
console.log('\n' + '─'.repeat(60));
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
const inconclusive = results.filter((r) => r.ok === null).length;
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode);
}
main().catch((e) => {
console.error('\x1b[31mHarness error:\x1b[0m', e);
process.exit(3);
});