579 lines
31 KiB
JavaScript
579 lines
31 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9
|
||
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||
*
|
||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
||
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at
|
||
* serve time so every fix is self-consistent through the render delay).
|
||
*
|
||
* Machine assertions:
|
||
* B1 ingest : both layers ingest the synthetic planes
|
||
* B2 billboards : every fleet billboard carries EXACTLY the expected
|
||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||
* imported from src) and per-class scale (flights:
|
||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
||
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
|
||
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||
* B4 clean : no console errors
|
||
*
|
||
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
|
||
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
|
||
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||
* acceptance test — read them.
|
||
*
|
||
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300
|
||
* Exits non-zero if any assertion fails. Never commits anything.
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import puppeteer from 'puppeteer';
|
||
import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js';
|
||
// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military
|
||
// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only
|
||
// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula.
|
||
const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||
const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']);
|
||
const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
|
||
: MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass]
|
||
: MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]);
|
||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Args (same shape as qa-heading-b3.mjs)
|
||
// ---------------------------------------------------------------------------
|
||
const argv = process.argv.slice(2);
|
||
const getFlag = (name) => argv.includes(name);
|
||
const getOpt = (name, dflt) => {
|
||
const i = argv.indexOf(name);
|
||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||
};
|
||
|
||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||
const HEADFUL = getFlag('--headful');
|
||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||
|
||
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
|
||
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
|
||
const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m)
|
||
const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE
|
||
|
||
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 { /* skip */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const results = [];
|
||
function record(name, ok, detail) {
|
||
results.push({ name, ok, detail });
|
||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Synthetic straight-flying aircraft.
|
||
// Rows (2D spread): flights row at lat 30.32 (6 category-driven classes),
|
||
// military row at lat 30.28 (all 8 classes via type codes). Clusters (3D
|
||
// scale comparison): tight 130–150 m spacing so the follow-camera frames
|
||
// several models at once below the minimumPixelSize clamp distance.
|
||
// courseDeg 0 = flying north (glyph nose-up in a north-up top-down view);
|
||
// two planes fly rotated courses to confirm sprites still follow the path.
|
||
// ---------------------------------------------------------------------------
|
||
const ROW_LON0 = -97.82;
|
||
const ROW_STEP_DEG = 0.012; // ~1.15 km — full military row (8 planes) fits the unobstructed frame center
|
||
const CLUSTER_LON0 = -97.7431;
|
||
const mDegLon = (m, lat) => m / (111320 * Math.cos((lat * Math.PI) / 180));
|
||
|
||
const SPRITES = {
|
||
timeOffsetSec: 0,
|
||
// OpenSky extended categories: 2 light, 4 large airliner, 6 heavy/widebody,
|
||
// 7 high-perf fastjet, 8 rotorcraft, 9 glider. (quadjet/turboprop need type
|
||
// codes, which OpenSky lacks — the military row covers those.)
|
||
flights: [
|
||
{ icao: 'caa001', callsign: 'FL-LGT', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2500, category: 2 },
|
||
{ icao: 'caa002', callsign: 'FL-AIR', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2600, category: 4 },
|
||
{ icao: 'caa003', callsign: 'FL-WID', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 80, altM: 2700, category: 6 },
|
||
{ icao: 'caa004', callsign: 'FL-FJ', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 90, speedMps: 80, altM: 2800, category: 7 },
|
||
{ icao: 'caa005', callsign: 'FL-HEL', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 1200, category: 8 },
|
||
{ icao: 'caa006', callsign: 'FL-GLD', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.32, courseDeg: 0, speedMps: 40, altM: 2000, category: 9 },
|
||
// 3D cluster (150 m spacing): widebody vs airliner (tracked) vs fastjet
|
||
{ icao: 'cbb001', callsign: '3D-WID', lon0: CLUSTER_LON0 - mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 6 },
|
||
{ icao: 'cbb002', callsign: '3D-AIR', lon0: CLUSTER_LON0, lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 4 },
|
||
{ icao: 'cbb003', callsign: '3D-FJ', lon0: CLUSTER_LON0 + mDegLon(150, 30.10), lat0: 30.10, courseDeg: 0, speedMps: 60, altM: 3000, category: 7 },
|
||
],
|
||
military: [
|
||
{ hex: 'dda001', flight: 'ML-LGT', t: 'C172', lon0: ROW_LON0 + 0 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 60, altFt: 9000 },
|
||
{ hex: 'dda002', flight: 'ML-GLD', t: 'DISC', lon0: ROW_LON0 + 1 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 8000 },
|
||
{ hex: 'dda003', flight: 'ML-TPR', t: 'C130', lon0: ROW_LON0 + 2 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 10000 },
|
||
{ hex: 'dda004', flight: 'ML-AIR', t: 'A320', lon0: ROW_LON0 + 3 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 11000 },
|
||
{ hex: 'dda005', flight: 'ML-WID', t: 'C17', lon0: ROW_LON0 + 4 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 12000 },
|
||
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
|
||
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
|
||
// course 0 like the rest — keeps the row a clean size-comparison line (2D
|
||
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
|
||
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
|
||
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
|
||
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||
// MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and
|
||
// 450–900 m shot ranges as the flights cluster.
|
||
{ hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||
{ hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||
{ hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||
{ hex: 'ddb004', flight: '3DMTPR', t: 'C130', lon0: CLUSTER_LON0 - mDegLon(300, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 },
|
||
],
|
||
};
|
||
|
||
// Node-side expected classification (same inputs the layers see).
|
||
const expectedFlights = new Map(SPRITES.flights.map((p) => [p.icao, classifyAircraft({ category: p.category })]));
|
||
const expectedMilitary = new Map(SPRITES.military.map((p) => [p.hex, classifyAircraft({ typeCode: p.t })]));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
async function main() {
|
||
console.log(`\nType-aware sprite QA (Batch 5 / skylight Tasks 8+9)`);
|
||
console.log(` App URL : ${APP_URL}\n`);
|
||
console.log(` Expected classes — flights: ${[...expectedFlights.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}`);
|
||
console.log(` Expected classes — military: ${[...expectedMilitary.entries()].map(([k, v]) => `${k}:${v}`).join(' ')}\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(SHOT_DIR, { recursive: true });
|
||
|
||
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 = [];
|
||
const httpErrors = [];
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width: 1280, height: 800 });
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') {
|
||
const text = msg.text();
|
||
if (!/Failed to load resource.*404/i.test(text)) consoleErrors.push(text);
|
||
}
|
||
});
|
||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||
page.on('response', (response) => {
|
||
if (response.status() >= 500) httpErrors.push(`${response.status()} ${response.url()}`);
|
||
});
|
||
|
||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||
await page.evaluateOnNewDocument((spec) => {
|
||
window.__SPR = spec;
|
||
window.__SPR.epochMs = Date.now();
|
||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||
|
||
// Straight line from (lon0, lat0) along courseDeg at speedMps.
|
||
window.__SPR.stateAt = (p, tSec) => {
|
||
const cRad = (p.courseDeg * Math.PI) / 180;
|
||
const dist = p.speedMps * tSec;
|
||
const east = dist * Math.sin(cRad);
|
||
const north = dist * Math.cos(cRad);
|
||
const lat = p.lat0 + north / 111320;
|
||
const lon = p.lon0 + east / (111320 * Math.cos((p.lat0 * Math.PI) / 180));
|
||
return { lon, lat, course: p.courseDeg, speedMps: p.speedMps };
|
||
};
|
||
|
||
const realFetch = window.fetch.bind(window);
|
||
const jsonResponse = (obj) => new Response(JSON.stringify(obj), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
window.fetch = (input, init) => {
|
||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||
const S = window.__SPR;
|
||
if (url.includes('/api/openai/hud-summary')) {
|
||
return Promise.resolve(jsonResponse({ summary: 'Aircraft scale QA' }));
|
||
}
|
||
const nowSec = Date.now() / 1000 + (S.timeOffsetSec || 0);
|
||
const tRel = nowSec - S.epochMs / 1000;
|
||
|
||
if (url.includes('/api/opensky-track')) return Promise.resolve(jsonResponse({ path: [] }));
|
||
if (url.includes('/api/adsblol/trace')) {
|
||
return Promise.resolve(jsonResponse({ timestamp: Math.floor(nowSec), trace: [] }));
|
||
}
|
||
if (url.includes('/api/opensky')) {
|
||
window.__SPR_HITS.opensky++;
|
||
const states = S.flights.map((f) => {
|
||
const s = S.stateAt(f, tRel);
|
||
return [
|
||
f.icao, f.callsign, 'Synthetica',
|
||
Math.floor(nowSec), Math.floor(nowSec),
|
||
s.lon, s.lat, f.altM,
|
||
false, // on_ground
|
||
s.speedMps, s.course,
|
||
0, null, null, null, false, 0,
|
||
f.category, // state[17] — extended emitter category
|
||
];
|
||
});
|
||
return Promise.resolve(jsonResponse({ time: Math.floor(nowSec), states }));
|
||
}
|
||
if (url.includes('/api/adsblol/mil')) {
|
||
window.__SPR_HITS.mil++;
|
||
const ac = S.military.map((m) => {
|
||
const s = S.stateAt(m, tRel);
|
||
return {
|
||
hex: m.hex, flight: m.flight,
|
||
lon: s.lon, lat: s.lat, alt_baro: m.altFt,
|
||
track: s.course, gs: s.speedMps * 1.9438,
|
||
t: m.t, r: `SY-${m.hex.slice(-3)}`, ownOp: 'SYNTH AF',
|
||
seen_pos: Math.max(0, -(S.timeOffsetSec || 0)),
|
||
};
|
||
});
|
||
return Promise.resolve(jsonResponse({ msg: 'No error', now: Date.now(), ac }));
|
||
}
|
||
// Synthetic categories/types already exercise every classification
|
||
// branch. ADSBDB enrichment is incidental to this visual-scale check,
|
||
// so keep an unavailable public enrichment provider from generating an
|
||
// unrelated console error.
|
||
if (url.includes('/api/adsbdb/')) return Promise.resolve(jsonResponse({ found: false }));
|
||
return realFetch(input, init);
|
||
};
|
||
}, SPRITES);
|
||
|
||
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, polling: 200 }
|
||
);
|
||
console.log(' App globals ready.');
|
||
|
||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||
await page.evaluate(() => {
|
||
// Every billboard (image + alignedAxis + id) in every collection.
|
||
window.__collectBillboards = function () {
|
||
const v = window.__godsEyeView.viewer;
|
||
const out = [];
|
||
const walk = (coll) => {
|
||
const n = coll.length;
|
||
for (let i = 0; i < n; i++) {
|
||
let p;
|
||
try { p = coll.get(i); } catch { continue; }
|
||
if (!p) continue;
|
||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||
}
|
||
}
|
||
};
|
||
walk(v.scene.primitives);
|
||
return out;
|
||
};
|
||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||
window.__collectModels = function () {
|
||
const v = window.__godsEyeView.viewer;
|
||
const out = [];
|
||
const walk = (coll) => {
|
||
const n = coll.length;
|
||
for (let i = 0; i < n; i++) {
|
||
let p;
|
||
try { p = coll.get(i); } catch { continue; }
|
||
if (!p) continue;
|
||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
|
||
let radius = null;
|
||
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
|
||
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
|
||
}
|
||
}
|
||
};
|
||
walk(v.scene.primitives);
|
||
return out;
|
||
};
|
||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||
const S = window.__SPR;
|
||
const p = S[layer][idx];
|
||
const tRel = (Date.now() - S.epochMs) / 1000 - renderDelaySec;
|
||
return S.stateAt(p, tRel);
|
||
};
|
||
// Canvas coordinates of every synthetic plane at its displayed position —
|
||
// ground truth for identifying which glyph is which in a screenshot.
|
||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
||
const v = window.__godsEyeView.viewer;
|
||
const S = window.__SPR;
|
||
const C3 = v.camera.position.constructor;
|
||
return S[layer].map((p, i) => {
|
||
const s = window.__displayedState(layer, i, renderDelaySec);
|
||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||
}).join(' ');
|
||
};
|
||
});
|
||
|
||
// ---- Prime history through the render delay (same pattern as b3) -------
|
||
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||
const primed = await page.evaluate(async () => {
|
||
const dm = window.__godsEyeView.dataManager;
|
||
const v = window.__godsEyeView.viewer;
|
||
window.__SPR.timeOffsetSec = -32;
|
||
await dm.setEnabled('flights', true);
|
||
await dm.setEnabled('military', true);
|
||
const fl = dm.layers.get('flights').module;
|
||
const mil = dm.layers.get('military').module;
|
||
for (const off of [-24, -16, -8, 0]) {
|
||
window.__SPR.timeOffsetSec = off;
|
||
await fl.update(v);
|
||
await mil.update(v);
|
||
}
|
||
window.__SPR.timeOffsetSec = 0;
|
||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
||
});
|
||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||
record('B1 ingest: synthetic planes in both layers',
|
||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
||
|
||
// ========================================================================
|
||
// B2 — billboard glyph + scale per class (machine check)
|
||
// ========================================================================
|
||
console.log('\nB2 — per-class billboard glyph + scale');
|
||
const bbs = await page.evaluate(() => window.__collectBillboards());
|
||
const bbById = new Map(bbs.map((b) => [b.id, b]));
|
||
let glyphBad = [];
|
||
let scaleBad = [];
|
||
for (const [id, klass] of expectedFlights) {
|
||
const bb = bbById.get(id);
|
||
const wantImg = aircraftIcon(klass);
|
||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||
}
|
||
for (const [id, klass] of expectedMilitary) {
|
||
const bb = bbById.get(id);
|
||
const wantImg = aircraftIcon(klass);
|
||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||
}
|
||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`);
|
||
record('B2b: every synthetic billboard has its class scale',
|
||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
||
|
||
// ========================================================================
|
||
// 2D screenshots
|
||
// ========================================================================
|
||
console.log('\n2D screenshots → qa-shots/b5/');
|
||
const topDown = async (lat, lon, height) => {
|
||
await page.evaluate(({ lat, lon, height }) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||
const C3 = v.camera.position.constructor;
|
||
v.camera.setView({
|
||
destination: C3.fromDegrees(lon, lat, height),
|
||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||
});
|
||
}, { lat, lon, height });
|
||
await sleep(1800); // let fleet tick land positions + rotations at the new pose
|
||
};
|
||
// Zoom the follow-camera to an absolute range from the tracked entity.
|
||
// In tracked mode camera.position lives in the ENTITY's reference frame, so
|
||
// its magnitude IS the range — fixed zoomIn() amounts compound across phases
|
||
// (the second tracked plane inherits the previous phase's close-in offset).
|
||
const zoomToRange = async (want) => {
|
||
await page.evaluate((want) => {
|
||
const v = window.__godsEyeView.viewer;
|
||
const p = v.camera.position;
|
||
const d = Math.hypot(p.x, p.y, p.z);
|
||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
||
v.camera.zoomIn(d - want);
|
||
}, want);
|
||
await sleep(1500);
|
||
};
|
||
// Rows drift north while flying; recenter on the DISPLAYED (render-delayed) row.
|
||
const rowLat = async (layer, idx, delay) =>
|
||
(await page.evaluate(({ layer, idx, delay }) => window.__displayedState(layer, idx, delay), { layer, idx, delay })).lat;
|
||
|
||
const flRowLat = await rowLat('flights', 2, 30);
|
||
const milRowLat = await rowLat('military', 4, 15);
|
||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') });
|
||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||
|
||
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
|
||
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
|
||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||
|
||
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
|
||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||
|
||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
||
await page.evaluate((icao) => {
|
||
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||
}, 'caa002');
|
||
await sleep(3000);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||
|
||
// ========================================================================
|
||
// B3 + 3D screenshots — flights cluster
|
||
// ========================================================================
|
||
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||
await page.evaluate(() => {
|
||
const dm = window.__godsEyeView.dataManager;
|
||
dm.layers.get('flights').module.setParams({ models3d: true });
|
||
dm.layers.get('flights').module.trackById('cbb002');
|
||
});
|
||
const flModelsUp = await page.waitForFunction(() => {
|
||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||
const ids = new Set(models.map((m) => m.id));
|
||
return ids.has('cbb001') && ids.has('cbb002') && ids.has('cbb003');
|
||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||
record('3D flights: tracked model + 2 fleet cluster models rendered', flModelsUp, flModelsUp ? 'all up' : 'timed out');
|
||
|
||
if (flModelsUp) {
|
||
const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||
const modelScaleBad = [];
|
||
for (const [id, want] of [
|
||
['cbb001', flightsWantScale(expectedFlights.get('cbb001'))],
|
||
['cbb003', flightsWantScale(expectedFlights.get('cbb003'))],
|
||
]) {
|
||
const m = flModels.find((x) => x.id === id);
|
||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||
}
|
||
const trackedM = flModels.find((x) => x.id === 'cbb002');
|
||
const trackedWant = flightsWantScale(expectedFlights.get('cbb002'));
|
||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||
}
|
||
record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)',
|
||
modelScaleBad.length === 0,
|
||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||
console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||
|
||
await sleep(1500);
|
||
await zoomToRange(900);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||
await zoomToRange(450);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||
}
|
||
|
||
// ========================================================================
|
||
// B3 + 3D screenshots — military cluster
|
||
// ========================================================================
|
||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||
await page.evaluate(() => {
|
||
const dm = window.__godsEyeView.dataManager;
|
||
dm.layers.get('military').module.setParams({ models3d: true });
|
||
dm.layers.get('military').module.trackById('ddb002');
|
||
});
|
||
const milModelsUp = await page.waitForFunction(() => {
|
||
const models = window.__collectModels().filter((m) => m.ready && m.show);
|
||
const ids = new Set(models.map((m) => m.id));
|
||
return ids.has('ddb001') && ids.has('ddb002') && ids.has('ddb003') && ids.has('ddb004');
|
||
}, { timeout: 40000, polling: 400 }).then(() => true).catch(() => false);
|
||
record('3D military: tracked model + 3 fleet cluster models rendered', milModelsUp, milModelsUp ? 'all up' : 'timed out');
|
||
|
||
if (milModelsUp) {
|
||
const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready));
|
||
const modelScaleBad = [];
|
||
for (const [id, want] of [
|
||
['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))],
|
||
['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))],
|
||
['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))],
|
||
]) {
|
||
const m = milModels.find((x) => x.id === id);
|
||
if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`);
|
||
}
|
||
const trackedM = milModels.find((x) => x.id === 'ddb002');
|
||
const trackedWant = militaryWantScale(expectedMilitary.get('ddb002'));
|
||
if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) {
|
||
modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`);
|
||
}
|
||
record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)',
|
||
modelScaleBad.length === 0,
|
||
modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry');
|
||
console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`);
|
||
|
||
await sleep(1500);
|
||
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
|
||
// flights cluster (close zoom no longer puts the camera inside a model).
|
||
await zoomToRange(900);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||
await zoomToRange(450);
|
||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||
}
|
||
|
||
record('B4: no console errors during QA run', consoleErrors.length === 0,
|
||
consoleErrors.length
|
||
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ')}; HTTP: ${httpErrors.slice(0, 8).join(' | ') || 'none observed'}`
|
||
: 'clean');
|
||
|
||
finish();
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
function finish() {
|
||
const failed = results.filter((r) => r.ok === false);
|
||
const passed = results.filter((r) => r.ok === true);
|
||
console.log(`\n${'─'.repeat(60)}`);
|
||
console.log(` RESULT: ${passed.length} passed, ${failed.length} failed`);
|
||
console.log(`${'─'.repeat(60)}\n`);
|
||
process.exitCode = failed.length > 0 ? 1 : 0;
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('\n\x1b[31mQA harness error:\x1b[0m', err && err.stack ? err.stack : err);
|
||
process.exit(2);
|
||
});
|