gods-eye-view/scripts/qa-cockpit-plates.mjs

556 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* Rendered proof for backdrop-selective callout plates at GROUND LEVEL.
*
* The owner's scenario: a cockpit sitting at JFK, where the geoid runs ~34 m
* under the ellipsoid so the HUD honestly reads a NEGATIVE altitude. Every
* label against the open sky came back with a full dark plate, because a
* sub-ellipsoid camera fell into `skyBackdropFactor`'s degenerate guard.
*
* WHAT THIS ASSERTS, AND WHY IT IS NOT A UNIT TEST IN DISGUISE. An earlier
* version of this harness called `skyBackdropFactor` directly and would have
* passed with the painter stubbed out — the number was right and nothing was
* drawn. Every assertion here now comes from the RENDERED path instead:
*
* ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the
* overlay's own published `visibleCount` / `labeledKeys`, so a contact only
* counts once the sweep, the arbiter and the placement solve have all run.
* plate scale — tapped off `CanvasRenderingContext2D` while the frame paints.
* `paintDetectionCallout` fills the plate at `alpha * plateScale` and the
* accent bar at `alpha`, in that order, then draws the callsign, so the
* ratio of the two fills IS the plate scale and the callsign attributes it
* to a named contact. Nothing is recomputed; this reads what was painted.
* pixels — the plate rect comes from the same tap (`roundRect` immediately
* precedes its `fill`), and the harness reads back mean alpha inside that
* rect on `#world-overlay-canvas`, the real compositing surface.
*
* `--teeth` proves those assertions have teeth: it stubs the canvas paint calls
* the callout painter uses to no-ops, so nothing reaches the framebuffer, and
* the plate-scale and pixel checks must go RED. Run it after any change here.
*
* Three poses over one synthetic contact field:
* level — cockpit on the ramp, nose on the horizon. This is the frame
* the owner shot: approach traffic against open sky.
* tilted-down — same cockpit, pitched down over the apron. Asserts that NO
* ground contact is labelled, which is not this fix's doing:
* from a camera under the ellipsoid Cesium's
* `EllipsoidalOccluder` culls every point below the local
* horizontal before detection ever sees it.
* lifted — the same view a moment after rotate, at +40 m of ellipsoid
* height. The apron contacts are back, and they keep their
* FULL plates: the proof that sky selectivity did not quietly
* become "no plates anywhere".
*
* Run (dev server already up):
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --tag before
* node scripts/qa-cockpit-plates.mjs --url http://localhost:4268 --teeth
*
* Defaults to the real GPU (ANGLE/Metal). `--swiftshader` selects software GL,
* which is deterministic but is NOT real-GPU evidence; the banner says which
* backend actually ran and the run records it alongside the results.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, fallback) => {
const index = argv.indexOf(name);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin;
const TAG = getOpt('--tag', 'after');
const HEADFUL = argv.includes('--headful');
const TEETH = argv.includes('--teeth');
const SWIFTSHADER = argv.includes('--swiftshader');
const SHOT_DIR = path.resolve('qa-shots/cockpitplates');
/** Mirrors `SKY_PLATE_SCALE` in src/overlays/worldOverlayTokens.js. */
const SKY_PLATE_SCALE = 0.22;
/** Painted alpha is a ratio of two floats; this is generous slack on it. */
const PLATE_SCALE_TOLERANCE = 0.02;
// JFK. Ramp ~4 m MSL with a geoid height of about -34 m, so ellipsoid height
// on the deck is ~-30 m and a widebody cockpit reads ALT -18 m: the owner's
// exact HUD number, and a camera genuinely INSIDE the ellipsoid.
const JFK_LON = -73.7781;
const JFK_LAT = 40.6413;
const COCKPIT_H = -18;
const RAMP_H = -30;
const M_PER_DEG_LAT = 110_574;
/**
* Contacts are placed due north of the cockpit, which faces north, so the sky
* set and the ground set share one frame and one screenshot can show both.
*/
const CONTACTS = [
// --- against open sky: above the cockpit's eye level ---
{ id: 'AAL1042', metric: '168KT', range: 6_000, height: 620, want: 'sky' },
{ id: 'DAL883', metric: '154KT', range: 9_000, height: 940, want: 'sky' },
{ id: 'JBU221', metric: '178KT', range: 12_000, height: 1_500, want: 'sky' },
{ id: 'UAL508', metric: '145KT', range: 4_200, height: 380, want: 'sky' },
{ id: 'FDX1190', metric: '190KT', range: 16_000, height: 2_400, want: 'sky' },
// --- against the apron: below the cockpit floor ---
{ id: 'N6172G', metric: '12KT', range: 150, height: RAMP_H, want: 'ground' },
{ id: 'SWA2255', metric: '8KT', range: 260, height: RAMP_H, want: 'ground' },
{ id: 'DAL2190', metric: '15KT', range: 400, height: RAMP_H - 4, want: 'ground' },
{ id: 'AAL77', metric: '6KT', range: 620, height: RAMP_H - 6, want: 'ground' },
];
const SKY_IDS = CONTACTS.filter((contact) => contact.want === 'sky').map((contact) => contact.id);
const GROUND_IDS = CONTACTS.filter((contact) => contact.want === 'ground').map((contact) => contact.id);
const POSES = [
{
name: 'level',
height: COCKPIT_H,
pitchDeg: -1.5,
// Approach traffic is above eye level, so it survives the occluder and must
// paint at the feathered scale.
expectPlated: SKY_IDS,
expectScale: SKY_PLATE_SCALE,
expectAbsent: GROUND_IDS,
note: 'sub-ellipsoid cockpit, nose on the horizon',
},
{
name: 'tilted-down',
height: COCKPIT_H,
pitchDeg: -14,
expectPlated: [],
expectScale: null,
expectAbsent: GROUND_IDS,
note: 'same cockpit pitched down; the occluder owns this emptiness',
},
{
name: 'lifted',
height: 40,
pitchDeg: -14,
expectPlated: GROUND_IDS,
expectScale: 1,
expectAbsent: [],
note: 'above the ellipsoid after rotate; apron plates at full strength',
},
];
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Version-pinned Chrome-for-Testing over the auto-updating system Chrome:
// its software-GL behavior shifts across majors and has produced false
// negatives in this repo's harnesses before.
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? `${detail}` : ''}`);
}
const stubJson = (body) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
/**
* Installed before any app code runs. Records the callout painter's own canvas
* calls; under `--teeth` it replaces them with no-ops instead, so the painter
* stops reaching the framebuffer and every rendered assertion should fail.
*/
function installPaintTap(teeth) {
const proto = CanvasRenderingContext2D.prototype;
const origRoundRect = proto.roundRect;
const origFill = proto.fill;
const origFillText = proto.fillText;
window.__PLATE_TAP = { on: false, events: [], lastRect: null, teeth };
if (teeth) {
// Stub the painter: no record, no pixels. This is the negative control.
proto.roundRect = function () {};
proto.fill = function () {};
proto.fillText = function () {};
return;
}
proto.roundRect = function (x, y, w, h, r) {
if (window.__PLATE_TAP.on) window.__PLATE_TAP.lastRect = { x, y, w, h };
return origRoundRect.call(this, x, y, w, h, r);
};
proto.fill = function (...args) {
if (window.__PLATE_TAP.on) {
window.__PLATE_TAP.events.push({
kind: 'fill',
alpha: this.globalAlpha,
rect: window.__PLATE_TAP.lastRect || null,
});
}
return origFill.apply(this, args);
};
proto.fillText = function (text, x, y, ...rest) {
if (window.__PLATE_TAP.on) {
window.__PLATE_TAP.events.push({ kind: 'text', alpha: this.globalAlpha, text: String(text) });
}
return origFillText.call(this, text, x, y, ...rest);
};
}
async function main() {
const backend = SWIFTSHADER ? 'software GL (SwiftShader)' : 'real GPU (ANGLE/Metal)';
console.log('\nCockpit Plate Backdrop QA — rendered path');
console.log(` App URL : ${APP_URL}`);
console.log(` Tag : ${TAG}${TEETH ? ' (TEETH: painter stubbed, expect RED)' : ''}`);
console.log(` Backend : ${backend}\n`);
try {
const response = await fetch(APP_URL);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (error) {
console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`);
process.exit(2);
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const executablePath = CHROME_CANDIDATES.find((candidate) => {
try { return fs.existsSync(candidate); } catch { return false; }
});
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(SWIFTSHADER
? ['--use-gl=angle', '--use-angle=swiftshader']
: ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1280,800',
],
});
const consoleErrors = [];
const perPose = [];
let renderer = 'unknown';
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
page.on('console', (message) => {
if (message.type() !== 'error') return;
const text = message.text();
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
});
page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`));
await page.evaluateOnNewDocument(installPaintTap, TEETH);
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin !== APP_ORIGIN) return void request.continue();
// Silence the live feeds: the injected field is the only population.
if (url.pathname === '/api/ais-live') {
return void request.respond(stubJson({
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
newestPositionAt: null, lastMessageAt: null,
}));
}
if (url.pathname === '/api/adsblol/mil') {
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
}
if (url.pathname === '/api/opensky') {
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
}
if (url.pathname === '/api/opensky-track') {
return void request.respond(stubJson({ path: [] }));
}
if (url.pathname === '/api/openai/hud-summary') {
return void request.respond(stubJson({ summary: 'Cockpit plate QA' }));
}
return void request.continue();
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 90_000 });
await page.waitForFunction(
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
renderer = await page.evaluate(() => {
const gl = document.createElement('canvas').getContext('webgl2')
|| document.createElement('canvas').getContext('webgl');
if (!gl) return 'no webgl';
const ext = gl.getExtension('WEBGL_debug_renderer_info');
return ext
? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL))
: String(gl.getParameter(gl.RENDERER));
});
const softwareRenderer = /swiftshader|llvmpipe|software/i.test(renderer);
console.log(` GL : ${renderer}\n`);
record(
SWIFTSHADER
? 'running on software GL as requested (structural checks, not GPU evidence)'
: 'running on the real GPU, so the screenshots are real-GPU evidence',
SWIFTSHADER ? softwareRenderer : !softwareRenderer,
renderer,
);
// Inject the field. The layers keep their real pipelines; only the source
// of observations is replaced, exactly as scripts/qa-labels.mjs does.
await page.evaluate((payload) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor;
window.__PLATE_QA_FIELD = payload.contacts.map((contact) => ({
sourceId: contact.id.toLowerCase(),
id: contact.id,
metric: contact.metric,
position: Cartesian3.fromDegrees(
payload.lon,
payload.lat + contact.range / payload.mPerDegLat,
contact.height,
),
type: 'AIR',
tier: 'civil',
}));
for (const layerId of ['flights', 'military', 'satellites']) {
const entry = dataManager.layers.get(layerId);
if (!entry?.module) continue;
entry.module.getDetectableObjects = () => (
layerId === 'flights' ? window.__PLATE_QA_FIELD : []
);
}
styleManager.setDetection({ enabled: true, densityPct: 100, allocationStrategy: 'elastic' });
}, { lon: JFK_LON, lat: JFK_LAT, mPerDegLat: M_PER_DEG_LAT, contacts: CONTACTS });
for (const pose of POSES) {
console.log(`\n — pose "${pose.name}" (${pose.note})`);
await page.evaluate((payload) => {
const { viewer } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
viewer.camera.setView({
destination: Cartesian3.fromDegrees(payload.lon, payload.lat, payload.height),
orientation: { heading: 0, pitch: payload.pitchDeg * Math.PI / 180, roll: 0 },
});
}, { lon: JFK_LON, lat: JFK_LAT, height: pose.height, pitchDeg: pose.pitchDeg });
// Let the tiles settle and the label solve run before the tap window.
await new Promise((resolve) => setTimeout(resolve, 6_000));
await page.evaluate(() => {
window.__PLATE_TAP.events = [];
window.__PLATE_TAP.on = true;
// Drive the frames this measurement needs, instead of assuming something
// else is holding the render loop open.
//
// This harness enables no data layer and never enters real Cockpit (which
// takes its own hold), so nothing in the scene animates. It used to get
// its frames for free from detection's continuous-render hold — and that
// hold was itself the bug: it pinned every idle tab at 60 fps, which is
// why it was removed (see src/data/detectionRenderDemand.js). The scene is
// now correctly idle during the tap window, so a measurement of PAINT
// OUTPUT has to ask for its own renders. Production is unaffected: real
// Cockpit holds, and a parked map keeps showing the last correct frame
// precisely because nothing changed.
const scene = window.__godsEyeView.viewer.scene;
const pump = () => {
if (!window.__PLATE_TAP.on) return;
scene.requestRender();
requestAnimationFrame(pump);
};
pump();
});
await new Promise((resolve) => setTimeout(resolve, 1_200));
const observed = await page.evaluate((wantedIds) => {
window.__PLATE_TAP.on = false;
const events = window.__PLATE_TAP.events;
const wanted = new Set(wantedIds);
// paintDetectionCallout paints, in order: the plate (alpha*plateScale),
// the accent bar (alpha), the leader, then the callsign. So the two
// fills before a callsign are that callout's plate and accent, and
// their ratio is the plate scale that was actually painted.
const byId = new Map();
for (let i = 0; i < events.length; i++) {
const event = events[i];
if (event.kind !== 'text') continue;
const id = event.text.trim();
if (!wanted.has(id)) continue;
const fills = [];
for (let j = i - 1; j >= 0 && fills.length < 2; j--) {
if (events[j].kind === 'fill') fills.unshift(events[j]);
}
if (fills.length < 2) continue;
const [plate, accent] = fills;
if (!(accent.alpha > 0)) continue;
byId.set(id, {
id,
plateAlpha: plate.alpha,
accentAlpha: accent.alpha,
plateScale: plate.alpha / accent.alpha,
rect: plate.rect,
});
}
// Pixel readback from the real compositing surface, over the plate rect
// the painter itself just used.
const canvas = document.getElementById('world-overlay-canvas');
const ctx = canvas?.getContext('2d', { willReadFrequently: true });
const dpr = canvas && canvas.clientWidth ? canvas.width / canvas.clientWidth : 1;
for (const entry of byId.values()) {
entry.meanPixelAlpha = null;
if (!ctx || !entry.rect || !(entry.rect.w > 0) || !(entry.rect.h > 0)) continue;
const x = Math.max(0, Math.round(entry.rect.x * dpr));
const y = Math.max(0, Math.round(entry.rect.y * dpr));
const w = Math.min(canvas.width - x, Math.round(entry.rect.w * dpr));
const h = Math.min(canvas.height - y, Math.round(entry.rect.h * dpr));
if (!(w > 0) || !(h > 0)) continue;
const data = ctx.getImageData(x, y, w, h).data;
let total = 0;
for (let p = 3; p < data.length; p += 4) total += data[p];
entry.meanPixelAlpha = total / (data.length / 4) / 255;
}
const diagnostics = window.__godsEyeView.styleManager.getDetectionDiagnostics();
return {
plated: [...byId.values()],
visibleCount: diagnostics?.visibleCount ?? null,
labeledKeys: diagnostics?.labeledKeys ?? null,
profile: diagnostics?.profile ?? null,
fillEvents: events.filter((event) => event.kind === 'fill').length,
};
}, [...pose.expectPlated, ...pose.expectAbsent]);
const labelledIds = (observed.labeledKeys || [])
.filter((key) => key.startsWith('flights:'))
.map((key) => key.slice('flights:'.length).toUpperCase());
console.log(
` diagnostics: profile=${observed.profile} visibleCount=${observed.visibleCount}`
+ ` labelled=[${labelledIds.join(' ')}] fills=${observed.fillEvents}`,
);
for (const entry of observed.plated) {
console.log(
` ${entry.id.padEnd(8)} plateScale=${entry.plateScale.toFixed(4)}`
+ ` (plate ${entry.plateAlpha.toFixed(4)} / accent ${entry.accentAlpha.toFixed(4)})`
+ ` meanPixelAlpha=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(4)}`,
);
}
// 1. The overlay actually rendered something this pose.
record(
`${pose.name}: the overlay reports live rendered contacts`,
Number.isFinite(observed.visibleCount) && observed.visibleCount > 0,
`visibleCount=${observed.visibleCount}`,
);
// 2. The contacts we expect to be labelled are labelled, by id.
const missingLabels = pose.expectPlated.filter((id) => !labelledIds.includes(id));
record(
`${pose.name}: every expected contact reaches the label solve`,
missingLabels.length === 0,
missingLabels.length
? `missing ${missingLabels.join(',')}`
: `${pose.expectPlated.length} labelled`,
);
// 3. The contacts that must NOT be labelled are absent.
const unexpected = pose.expectAbsent.filter((id) => labelledIds.includes(id));
record(
`${pose.name}: below-eye-level contacts stay out of the solve`,
unexpected.length === 0,
unexpected.length
? `unexpectedly labelled ${unexpected.join(',')}`
: 'none labelled, as expected',
);
// 4/5/6. Painted plate scale and pixels, per named contact.
if (pose.expectPlated.length > 0) {
const platedIds = observed.plated.map((entry) => entry.id);
const missingPaint = pose.expectPlated.filter((id) => !platedIds.includes(id));
record(
`${pose.name}: the callout painter ran for every expected contact`,
missingPaint.length === 0,
missingPaint.length
? `no paint recorded for ${missingPaint.join(',')}`
: `${platedIds.length} painted`,
);
const offScale = observed.plated.filter(
(entry) => Math.abs(entry.plateScale - pose.expectScale) > PLATE_SCALE_TOLERANCE,
);
record(
`${pose.name}: painted plate scale is ${pose.expectScale}`,
observed.plated.length > 0 && offScale.length === 0,
offScale.length
? offScale.map((entry) => `${entry.id}=${entry.plateScale.toFixed(3)}`).join(' ')
: observed.plated.map((entry) => entry.plateScale.toFixed(3)).join(' '),
);
const inked = observed.plated.filter((entry) => (entry.meanPixelAlpha ?? 0) > 0.01);
record(
`${pose.name}: the plate rect carries real pixels on the overlay canvas`,
observed.plated.length > 0 && inked.length === observed.plated.length,
observed.plated
.map((entry) => `${entry.id}=${entry.meanPixelAlpha === null ? 'n/a' : entry.meanPixelAlpha.toFixed(3)}`)
.join(' '),
);
}
perPose.push({ pose: pose.name, ...observed, labelledIds });
const shot = path.join(SHOT_DIR, `${TAG}-${pose.name}.png`);
await page.screenshot({ path: shot });
console.log(` shot: ${shot}`);
}
// Cross-pose pixel comparison: the same theme, the same canvas, the same
// painter — only the backdrop differs, so sky plates must be lighter.
const skyInk = perPose.find((entry) => entry.pose === 'level')?.plated || [];
const groundInk = perPose.find((entry) => entry.pose === 'lifted')?.plated || [];
const meanOf = (rows) => (rows.length
? rows.reduce((sum, entry) => sum + (entry.meanPixelAlpha ?? 0), 0) / rows.length
: null);
const skyMean = meanOf(skyInk);
const groundMean = meanOf(groundInk);
record(
'sky-backed plates are measurably lighter on the canvas than ground-backed ones',
Number.isFinite(skyMean) && Number.isFinite(groundMean) && skyMean < groundMean,
`sky mean alpha ${skyMean === null ? 'n/a' : skyMean.toFixed(4)}`
+ ` vs ground ${groundMean === null ? 'n/a' : groundMean.toFixed(4)}`,
);
record(
'no page errors while the overlay ran at ground level',
consoleErrors.length === 0,
consoleErrors.slice(0, 3).join(' | ') || 'clean',
);
} finally {
await browser.close();
}
fs.writeFileSync(
path.join(SHOT_DIR, `${TAG}-rendered.json`),
`${JSON.stringify({
tag: TAG,
teeth: TEETH,
backend,
renderer,
cockpitHeight: COCKPIT_H,
poses: perPose,
}, null, 2)}\n`,
);
const failed = results.filter((entry) => !entry.ok);
console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`);
if (TEETH) {
// Under --teeth the run is EXPECTED to fail; a green run would mean the
// assertions do not depend on the painter and the harness is decorative.
const ok = failed.length > 0;
console.log(ok
? ` \x1b[32mTEETH OK\x1b[0m — ${failed.length} check(s) went red with the painter stubbed:\n ${failed.map((entry) => entry.name).join('\n ')}\n`
: ' \x1b[31mTEETH FAILED\x1b[0m — every check passed with the painter stubbed; the harness proves nothing.\n');
process.exit(ok ? 0 : 1);
}
process.exit(failed.length === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});