728 lines
38 KiB
JavaScript
728 lines
38 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|