mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself.
531 lines
23 KiB
JavaScript
531 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
import { evaluateWithGeo } from './qa/geometry.mjs';
|
|
/**
|
|
* 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/shellFacade.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
|
|
*
|
|
* QA_ANGLE selects the renderer (Metal by default); use swiftshader for 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 { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
|
|
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
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 = /swiftshader|software/i.test(process.env.QA_ANGLE || 'metal');
|
|
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 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 browser = await launchBrowser({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox', ...(SWIFTSHADER ? [] : ['--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/vessels') {
|
|
return void request.respond(stubJson({
|
|
rows: [], source: 'QA', status: 'open', error: null, refreshing: false,
|
|
newestPositionAt: null, lastMessageAt: null,
|
|
}));
|
|
}
|
|
if (url.pathname === '/api/military') {
|
|
return void request.respond(stubJson({ msg: 'No error', now: Date.now(), ac: [] }));
|
|
}
|
|
if (url.pathname === '/api/flights') {
|
|
return void request.respond(stubJson({ time: Math.floor(Date.now() / 1000), states: [] }));
|
|
}
|
|
if (url.pathname === '/api/flights/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 whenSettled(page);
|
|
|
|
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 (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 evaluateWithGeo(page, async (qaGeo, payload) => {
|
|
const { dataManager, styleManager } = window.__godsEyeView;
|
|
window.__godsEyeView.engine.camera.cancelFlight();
|
|
const Cartesian3 = qaGeo.cartesian3;
|
|
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 evaluateWithGeo(page, async (qaGeo, payload) => {
|
|
const Cartesian3 = qaGeo.cartesian3;
|
|
window.__godsEyeView.engine.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 whenTilesLoaded(page);
|
|
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 pump = () => {
|
|
if (!window.__PLATE_TAP.on) return;
|
|
window.__godsEyeView.engine.render.request();
|
|
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 closeBrowser(browser);
|
|
}
|
|
|
|
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);
|
|
});
|