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

+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 || '?'}]`,
);
}