test(qa): an engine-neutral layer probe

- src/app/qaProbe.js → window.__godsEyeView.layers: list() (with
  `available`), unavailable(), available(id), enable/disable(id) (a user
  toggle through the layer manager), objects(id) → [{ id, label, position,
  screen }], screenOf(id, objectId), selected(id), following(), movers(),
  framesDrawn() - all through the engine and the catalog's availability, the
  same on Cesium and Navara. Installed after the tools.
- src/app/layers/qaSources.js: `?qaFeed=flights:synthetic:10000,
  vessels:fixture` (dev server only, allowQaRegistration) replaces those
  sources with seeded synthetic answers served through the live sources'
  own parsers (same snapshot shape; loaded with import() only then). The
  pure generators moved from src/tooling/w1Feeds.js to src/bench/w1Feeds.js
  (the browser graph may not reach src/tooling); the W1 recordings
  re-export them.
- scripts/qa-w1-layers.mjs: the parity checklist's frame, --layer
  --engine=cesium|navara with the fixture feeds, JSON per run and
  --compare (objects on screen per pose, 5 known objects within 2 px).
  Filled: 1 view, 8 idle, 9 errors/no leftover mover sets after 10
  on/off; 2-7 report TODO for the layer tasks.
- qa-perf, qa-labels, qa-overlay-baseline and qa-label-readability take
  --engine= and use the engine (camera, frames, render requests, tiles) and
  the probe (layers, following, movers) instead of the viewer; checks whose
  layers an engine lacks are SKIPPED with the reason (Navara today), as are
  qa-perf's animated-style checks there (Cesium post stages).

Verdicts, Cesium, before → after: qa-perf 24/24 → 24/24, qa-labels 14/14 →
14/14, qa-overlay-baseline (datacenters, firms, detection-50) 2 measured +
1 skipped → the same. Navara: qa-perf 14 passed, 3 skipped; qa-labels 3/3,
1 skipped; qa-overlay-baseline 3 skipped; qa-w1-layers flights: Cesium 5/5
(10 000 synthetic aircraft), Navara skipped (no flights renderer yet).
This commit is contained in:
German Khokhlov committed 2026-10-16 22:52:18 +04:00
1 parent 834d34296f
commit ec7dcc944e
11 files changed
+754 -126

No files matched your search

+7 -1
View File
@@ -1101,5 +1101,11 @@
"src/app/layers/renderers.js",
"src/app/layers/renderers.test.mjs",
"src/data/lifecycleEngine.test.mjs",
"src/ui/shareRestorationEngine.test.mjs"
"src/ui/shareRestorationEngine.test.mjs",
"src/app/qaProbe.js",
"src/app/qaProbe.test.mjs",
"src/app/layers/qaSources.js",
"src/app/layers/qaSources.test.mjs",
"scripts/qa-w1-layers.mjs",
"src/bench/w1Feeds.js"
]
+33 -19
View File
@@ -4,7 +4,11 @@
* legibility over bright ground.
*
* Run:
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
*
* Engine-neutral: camera, tiles and layers go through the globe engine and the
* QA probe; an engine without the flights/satellites layers (Navara today)
* shoots the scenes without a contact field and says so.
*
* Every scene pins its own camera pose AND substitutes a deterministic
* synthetic contact field for the live pollers, so a `before` run and an
@@ -36,7 +40,12 @@ const getOpt = (name, fallback) => {
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const APP_URL = getOpt('--url', 'http://localhost:4244');
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', 'http://localhost:4244'));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const TAG = getOpt('--tag', 'after');
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
const SCENE_FILTER = getOpt('--scenes', '')
@@ -205,11 +214,8 @@ async function settle(page) {
while (Date.now() - started < SETTLE_MAX_MS) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const state = await page.evaluate(() => {
const { viewer, tileset } = window.__godsEyeView;
return {
tilesLoaded: tileset?.tilesLoaded !== false,
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
};
const { engine } = window.__godsEyeView;
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
});
const settled = state.tilesLoaded && state.pending === 0;
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
@@ -254,7 +260,7 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
{ timeout: 60_000, polling: 100 },
);
// flyToAustin arrives ~500 ms after init; let it start and land before any
@@ -268,12 +274,16 @@ async function main() {
for (const scene of selected) {
process.stdout.write(` ${scene.id} … `);
await page.evaluate((spec) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor;
const fieldShown = await page.evaluate(async (spec) => {
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
const Cartesian3 = Object.assign(
(x, y, z) => geo.cartesian3.create(x, y, z),
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
);
viewer.camera.setView({
engine.camera.setView({
destination: Cartesian3.fromDegrees(
spec.camera.lon, spec.camera.lat, spec.camera.height,
),
@@ -290,10 +300,9 @@ async function main() {
// the frame comes back with two labels instead of a field. Building
// straight off the camera basis guarantees every contact lands inside
// the keyhole, at a known depth, in both the before and after runs.
const camera = viewer.camera;
const frustum = camera.frustum;
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
const camera = engine.camera.pose();
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
const halfWidth = halfHeight * (camera.aspectRatio || 1.6);
const basis = {
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
@@ -305,7 +314,7 @@ async function main() {
const pointAt = (ndcX, ndcY, distance) => {
const sx = ndcX * halfWidth * distance;
const sy = ndcY * halfHeight * distance;
return new Cartesian3(
return Cartesian3(
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
@@ -347,6 +356,9 @@ async function main() {
? { satellites: build('s'), flights: [], military: [] }
: { flights: build('f'), military: [], satellites: [] };
window.__LABEL_EVIDENCE = assignment;
const fieldLayers = Object.keys(assignment).filter(
(layerId) => assignment[layerId].length,
);
for (const layerId of Object.keys(assignment)) {
const entry = dataManager.layers.get(layerId);
if (entry?.module) {
@@ -362,6 +374,7 @@ async function main() {
densityPct: 100,
allocationStrategy: 'elastic',
});
return fieldLayers.every((layerId) => layers.available(layerId));
}, scene);
const converged = await settle(page);
@@ -399,10 +412,11 @@ async function main() {
const diagnostics = await page.evaluate(
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
);
results.push({ scene: scene.id, converged, ...stats });
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
console.log(
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
+ `${converged ? '' : ' (tiles did not fully settle)'}`
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
+ ` [${diagnostics.detectionMode || '?'}]`,
);
}
+64 -21
View File
@@ -3,7 +3,12 @@
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
*
* Run:
* node scripts/qa-labels.mjs --url http://localhost:4173
* node scripts/qa-labels.mjs --url http://localhost:4173 [--engine=cesium|navara]
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). On an engine
* without the flights, military and satellites layers (Navara today) the
* field checks are SKIPPED and the settings checks still run.
*
* The harness replaces only the three detection read APIs in the loaded page.
* It does not enable their network pollers or mutate repository data. The field
@@ -22,7 +27,12 @@ const getOpt = (name, fallback) => {
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', 'http://localhost:4173'));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const APP_ORIGIN = new URL(APP_URL).origin;
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/labels');
@@ -74,6 +84,8 @@ function findChromeExecutable() {
}
const results = [];
let skippedChecks = 0;
let skippedField = false;
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
@@ -271,7 +283,7 @@ async function main() {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
{ timeout: 60000, polling: 100 },
);
@@ -279,7 +291,7 @@ async function main() {
// Let that callback start, then cancel it before establishing the global
// measurement camera so startup motion cannot invalidate the sample.
await new Promise((resolve) => setTimeout(resolve, 600));
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight());
const stateChecks = await page.evaluate((storageKey) => {
const manager = window.__godsEyeView.styleManager;
@@ -305,10 +317,32 @@ async function main() {
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
);
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
const { viewer, dataManager, styleManager } = window.__godsEyeView;
viewer.camera.cancelFlight();
const Cartesian3 = viewer.camera.position.constructor;
const fieldLayers = ['flights', 'military', 'satellites'];
const missingLayers = await page.evaluate(
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
fieldLayers,
);
if (missingLayers.length) {
console.log(` [SKIP] the deterministic field checks — no ${missingLayers.join(', ')} on ${ENGINE}`);
skippedChecks += 1;
record(
'console remains clean',
consoleErrors.length === 0,
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
);
skippedField = true;
return;
}
const injected = await page.evaluate(async ({ fieldCounts, normalCounts }) => {
const { engine, dataManager, styleManager } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
// A frame counter on the engine (the samples are one per drawn frame).
window.__LABEL_QA_FRAME = 0;
engine.frames.postRender.addEventListener(() => {
window.__LABEL_QA_FRAME += 1;
});
const Cartesian3 = { fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) };
const field = { flights: [], military: [], satellites: [] };
const layerIds = Object.keys(field);
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
@@ -339,10 +373,15 @@ async function main() {
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
}
viewer.camera.setView({
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
// The global camera, orbiting west to east (the old rotateRight about
// the globe centre at 25 000 km).
let orbitLon = -97;
const placeCamera = () =>
engine.camera.setView({
destination: Cartesian3.fromDegrees(orbitLon, 30, 25000000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
placeCamera();
styleManager.setDetection({
enabled: true,
densityPct: 100,
@@ -359,13 +398,14 @@ async function main() {
let lastOrbitAt = performance.now();
window.__LABEL_QA_ORBIT = setInterval(() => {
const now = performance.now();
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
lastOrbitAt = now;
viewer.scene.requestRender();
placeCamera();
engine.render.request('qa-labels');
}, 100);
const sample = () => {
const diagnostics = styleManager.getDetectionDiagnostics();
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
const frameNumber = window.__LABEL_QA_FRAME;
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
window.__LABEL_QA.lastFrameNumber = frameNumber;
window.__LABEL_QA.frames.push({
@@ -395,7 +435,7 @@ async function main() {
observationCount: Number(dataset.observationCount),
selectedCount: Number(dataset.selectedCount),
fadingCount: Number(dataset.fadingCount),
cameraHeight: viewer.camera.positionCartographic.height,
cameraHeight: engine.camera.height(),
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
@@ -420,12 +460,11 @@ async function main() {
// -samples gates supersede it — same fix, deterministic instead of timed.)
await waitForSettledSyntheticField(page);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
window.__LABEL_QA.solve = [];
window.__LABEL_QA.frames = [];
window.__LABEL_QA.lastRevision = revision;
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
window.__LABEL_QA.startedAt = performance.now();
});
const syntheticSampling = await waitForConclusiveSamples(page, {
@@ -460,12 +499,11 @@ async function main() {
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
window.__LABEL_QA.solve = [];
window.__LABEL_QA.frames = [];
window.__LABEL_QA.lastRevision = revision;
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
window.__LABEL_QA.startedAt = performance.now();
});
const normalSampling = await waitForConclusiveSamples(page, {
@@ -587,10 +625,15 @@ async function main() {
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
} finally {
await browser.close();
if (skippedField) summarize();
}
summarize();
}
function summarize() {
const failures = results.filter((result) => !result.ok);
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
console.log(`\n${results.length - failures.length}/${results.length} checks passed${skippedChecks ? `, ${skippedChecks} skipped` : ''} (engine ${ENGINE}).`);
if (failures.length) process.exit(1);
}
+75 -25
View File
@@ -15,6 +15,12 @@
* node scripts/qa-overlay-baseline.mjs --screenshots-dir /tmp/overlay-shots
* node scripts/qa-overlay-baseline.mjs --hardware-gpu --headful
* node scripts/qa-overlay-baseline.mjs --dist-dir /tmp/gev-dist
* node scripts/qa-overlay-baseline.mjs --engine=navara
*
* Engine-neutral: the camera and frames go through the globe engine and the
* layers through the QA probe (window.__godsEyeView.layers). A scene whose
* layers the engine does not have is SKIPPED; without a Cesium viewer the
* entity inventory reports the engine's mover sets instead.
*/
import fs from 'node:fs';
@@ -263,7 +269,10 @@ function printScene(result) {
}
if (result.entityInventory) {
const totals = result.entityInventory.totals;
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
if (totals.entities === undefined)
console.log(` scene total : mover sets ${totals.moverSets} · instances ${totals.moverInstances}`);
else
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
}
for (const phaseName of ['motion', 'rest']) {
const phase = result.samples?.[phaseName];
@@ -345,33 +354,29 @@ async function installPageInstrumentation(page) {
async function waitForApp(page) {
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
{ timeout: 60_000 },
);
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
try { viewer.camera.cancelFlight(); } catch { /* no flight */ }
try { viewer.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
const { engine, viewer } = window.__godsEyeView;
try { engine.camera.cancelFlight(); } catch { /* no flight */ }
try { viewer?.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
});
await sleep(1_250);
}
async function setCamera(page, camera) {
if (!camera) return;
await page.evaluate(([lon, lat, height, heading, pitch]) => {
const viewer = window.__godsEyeView.viewer;
const ellipsoid = viewer.scene.globe.ellipsoid;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: ellipsoid.cartographicToCartesian({
longitude: lon * Math.PI / 180,
latitude: lat * Math.PI / 180,
height,
}),
await page.evaluate(async ([lon, lat, height, heading, pitch]) => {
const { engine, viewer } = window.__godsEyeView;
const geo = await import('/src/geo/index.js');
engine.camera.cancelFlight();
viewer?.scene.tweens?.removeAll?.();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading, pitch, roll: 0 },
});
viewer.scene.requestRender?.();
engine.render.request('qa-overlay-baseline');
}, camera);
}
@@ -411,7 +416,19 @@ async function waitForLayer(page, layerId) {
async function readEntityInventory(page) {
return page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const { viewer, layers } = window.__godsEyeView;
// Without a Cesium viewer (Navara): the engine's mover sets.
if (!viewer) {
const sets = layers.movers();
return {
totals: {
moverSets: sets.length,
moverInstances: sets.reduce((sum, set) => sum + set.count, 0),
},
collections: sets,
primitiveTypes: {},
};
}
const time = viewer.clock.currentTime;
const collections = [];
const totals = {
@@ -605,7 +622,7 @@ async function prepareTrackedFlight(page, cockpit) {
});
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
try {
await page.waitForFunction(() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 });
await page.waitForFunction(() => Boolean(window.__godsEyeView.layers.following()), { timeout: 10_000 });
} catch {
return 'Civil tracking did not create a tracked entity';
}
@@ -638,7 +655,7 @@ async function prepareMission(page) {
});
if (!selected) return 'Mission roster did not expose a selectable mission';
try {
await page.waitForFunction(() => String(window.__godsEyeView.viewer.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
await page.waitForFunction(() => String(window.__godsEyeView.viewer?.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
} catch {
return 'Mission selection did not reach the Cesium selected entity';
}
@@ -647,6 +664,12 @@ async function prepareMission(page) {
}
async function prepareScene(page, scene) {
const missing = await page.evaluate(
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
scene.layers,
);
if (missing.length)
return { layerActivations: [], skipReason: `no ${missing.join(', ')} on ${ENGINE}` };
if (scene.camera) await setCamera(page, scene.camera);
const layerActivations = await activateLayers(page, scene.layers);
let skipReason = null;
@@ -678,7 +701,28 @@ async function readCanvasSummary(page) {
async function samplePhase(page, moving) {
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
const viewer = window.__godsEyeView.viewer;
const { engine } = window.__godsEyeView;
// Cesium's rotateRight / rotateUp: the camera turned about the axis
// through the globe centre along its up (right) vector.
const rotate = (axis, angle) => {
const pose = engine.camera.pose();
const len = Math.hypot(axis.x, axis.y, axis.z);
const [ux, uy, uz] = [axis.x / len, axis.y / len, axis.z / len];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const turn = (v) => {
const dot = ux * v.x + uy * v.y + uz * v.z;
return {
x: v.x * cos + (uy * v.z - uz * v.y) * sin + ux * dot * (1 - cos),
y: v.y * cos + (uz * v.x - ux * v.z) * sin + uy * dot * (1 - cos),
z: v.z * cos + (ux * v.y - uy * v.x) * sin + uz * dot * (1 - cos),
};
};
engine.camera.setView({
destination: turn(pose.position),
orientation: { direction: turn(pose.direction), up: turn(pose.up) },
});
};
const instrumentation = window.__overlayBaselineInstrumentation;
instrumentation?.reset?.();
const intervals = [];
@@ -690,10 +734,11 @@ async function samplePhase(page, moving) {
lastFrameAt = now;
const elapsed = now - startedAt;
if (movingCamera) {
viewer.camera.rotateRight(0.00035);
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
const pose = engine.camera.pose();
rotate(pose.up, -0.00035);
rotate(engine.camera.pose().right, 0.00008 * Math.sin(elapsed / 600));
}
viewer.scene.requestRender?.();
engine.render.request('qa-overlay-baseline');
if (elapsed >= durationMs) resolve();
else requestAnimationFrame(step);
};
@@ -813,7 +858,12 @@ async function runScene(browser, scene, shotsDir) {
}
}
const APP_URL = getOpt('--url', DEFAULT_URL);
const ENGINE = process.argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const APP_URL = (() => {
const url = new URL(getOpt('--url', DEFAULT_URL));
url.searchParams.set('engine', ENGINE);
return url.href;
})();
const DPR = Number(getOpt('--dpr', '1'));
const JSON_PATH = getOpt('--json');
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
+79 -57
View File
@@ -68,21 +68,35 @@
* "world-overlay honours occluder churn as paint work" in
* the project roadmap.
*
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173]
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173] [--engine=cesium|navara]
* Engine-neutral: the camera, frames and layers go through the globe engine
* and the QA probe (window.__godsEyeView.layers); on an engine without a
* layer (Navara today) that layer's checks are SKIPPED, not failed.
* Requires a running dev server. Headless; flags disable occlusion
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
*/
import puppeteer from 'puppeteer';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
const baseUrl = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
const engineKind = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
const url = (() => {
const parsed = new URL(baseUrl);
parsed.searchParams.set('engine', engineKind);
return parsed.href;
})();
const results = [];
let skipped = 0;
function check(name, pass, detail) {
results.push({ name, pass });
const tag = pass ? 'PASS' : 'FAIL';
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
}
function skip(name, reason) {
skipped += 1;
console.log(` [SKIP] ${name} — ${reason}`);
}
const browser = await puppeteer.launch({
headless: 'new',
@@ -103,20 +117,18 @@ try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 90_000 });
const layerAvailable = (id) => page.evaluate((layerId) => window.__godsEyeView.layers.available(layerId), id);
// Boot flyTo + tile warm + all deferred init.
await new Promise((r) => setTimeout(r, 15_000));
// Park deterministically and disable every layer.
await page.evaluate(async () => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight();
const ell = v.scene.globe.ellipsoid;
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 60_000,
}),
const geo = await import('/src/geo/index.js');
gev.engine.camera.cancelFlight();
gev.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(-97.74, 30.27, 60_000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
for (const [id, entry] of gev.dataManager.layers) {
@@ -128,9 +140,9 @@ try {
/** Count scene postRender fires and rAF ticks over windowMs. */
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
const { frames } = window.__godsEyeView.engine;
let renders = 0; let rafs = 0;
const remove = scene.postRender.addEventListener(() => { renders += 1; });
const remove = frames.postRender.addEventListener(() => { renders += 1; });
const t0 = performance.now();
const tick = () => {
rafs += 1;
@@ -255,10 +267,10 @@ try {
const detectMove = await Promise.all([
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
const { camera } = window.__godsEyeView.engine;
let steps = 0;
const id = setInterval(() => {
v.camera.moveForward(50);
camera.zoomIn(50);
steps += 1;
if (steps >= 20) { clearInterval(id); resolve(true); }
}, 60);
@@ -281,13 +293,13 @@ try {
// identity is one painted frame. Sampling it per scene frame is enough to
// tell "painting" from "silent" without reaching into module internals.
let last = gev.styleManager.getDetectionDiagnostics?.();
const remove = gev.viewer.scene.postRender.addEventListener(() => {
const remove = gev.engine.frames.postRender.addEventListener(() => {
const now = gev.styleManager.getDetectionDiagnostics?.();
if (now && now !== last) { paints += 1; last = now; }
});
let steps = 0;
const id = setInterval(() => {
gev.viewer.camera.moveForward(50);
gev.engine.camera.zoomIn(50);
steps += 1;
if (steps >= 20) {
clearInterval(id);
@@ -325,9 +337,15 @@ try {
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
const dAnim = await diag();
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
// The animated styles are Cesium post-process stages; Navara draws its own
// looks and has no style animation (a known gap, not a regression).
if (engineKind === 'cesium') {
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
} else {
skip('animated style takes the style-anim hold and keeps frames flowing', `the visual styles are Cesium post stages; ${engineKind} has none`);
}
// Returning to normal keeps detection ON by design (it is the default, and
// detection persists across style switches). Since 2026-08-22 detection holds
// nothing, so this asserts the strictly harder thing: the scene returns to
@@ -349,26 +367,26 @@ try {
);
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('satellites', true, { origin: 'user' });
});
const dSat = await diag();
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('satellites', false, { origin: 'user' });
});
await new Promise((r) => setTimeout(r, 2_000));
const dSatOff = await diag();
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
if (await layerAvailable('satellites')) {
await page.evaluate(() => window.__godsEyeView.layers.enable('satellites'));
const dSat = await diag();
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
await page.evaluate(() => window.__godsEyeView.layers.disable('satellites'));
await new Promise((r) => setTimeout(r, 2_000));
const dSatOff = await diag();
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
} else {
skip('satellites holder enters and leaves diagnostics', `no satellites layer on ${engineKind}`);
}
// ── 3. camera movement renders (Cesium-native path) ───────────────────
const duringMove = await Promise.all([
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
const { camera } = window.__godsEyeView.engine;
let steps = 0;
const id = setInterval(() => {
v.camera.moveForward(50);
camera.zoomIn(50);
steps += 1;
if (steps >= 20) { clearInterval(id); resolve(true); }
}, 60);
@@ -378,36 +396,40 @@ try {
await new Promise((r) => setTimeout(r, 3_000)); // settle
// ── 4. flights enabled → continuous ───────────────────────────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('flights', true, { origin: 'user' });
});
await new Promise((r) => setTimeout(r, 5_000));
const active = await countFrames(5_000);
const d4 = await diag();
check('governor reports continuous mode with flights on', d4?.mode === 'continuous', d4);
check('flights-on cadence ≈ rAF cadence (≥70%)', active.renders >= active.rafs * 0.7, active);
check('flights-on renders ≥5× idle renders', active.renders >= Math.max(1, idle.renders) * 5, { active: active.renders, idle: idle.renders });
if (await layerAvailable('flights')) {
await page.evaluate(async () => {
await window.__godsEyeView.layers.enable('flights');
});
await new Promise((r) => setTimeout(r, 5_000));
const active = await countFrames(5_000);
const d4 = await diag();
check('governor reports continuous mode with flights on', d4?.mode === 'continuous', d4);
check('flights-on cadence ≈ rAF cadence (≥70%)', active.renders >= active.rafs * 0.7, active);
check('flights-on renders ≥5× idle renders', active.renders >= Math.max(1, idle.renders) * 5, { active: active.renders, idle: idle.renders });
// ── 5. flights disabled → idle again ──────────────────────────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('flights', false, { origin: 'user' });
});
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
// its occluders against all have to drain first — and this teardown, like the
// one in the setup above, marks the HUD summary dirty, so the same deferred
// typewriter is still ahead of us. Same rule: hold a quiet run longer than one
// refresh period before counting anything.
const teardownSettle = await settleUntilQuiet();
const idleAgain = await countFrames(5_000);
const d5 = await diag();
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
check('governor returns to idle after disable', d5?.mode === 'idle', d5);
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
// ── 5. flights disabled → idle again ──────────────────────────────────
await page.evaluate(async () => {
await window.__godsEyeView.layers.disable('flights');
});
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
// its occluders against all have to drain first — and this teardown, like the
// one in the setup above, marks the HUD summary dirty, so the same deferred
// typewriter is still ahead of us. Same rule: hold a quiet run longer than one
// refresh period before counting anything.
const teardownSettle = await settleUntilQuiet();
const idleAgain = await countFrames(5_000);
const d5 = await diag();
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
check('governor returns to idle after disable', d5?.mode === 'idle', d5);
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
} else {
skip('flights on → continuous, off → idle again', `no flights layer on ${engineKind}`);
}
} finally {
await browser.close();
}
const passed = results.filter((r) => r.pass).length;
console.log(`\nqa-perf: ${passed}/${results.length} passed`);
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, ${skipped} skipped (engine ${engineKind})`);
process.exit(passed === results.length ? 0 : 1);