mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
@@ -0,0 +1,555 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in new issue
Block a user