test(qa): sprite and enrichment checks on both engines through the probe

The QA probe gains drawn(layerId): what a layer draws per object (glyph
image, scale, shown, screen rotation, alpha, position; its 3D model's
scale, shown, ready), from the flights layer's qaDrawn(), which reads the
renderer's handles, so it is the same on every engine.

qa-sprites-b5 and qa-enrich-ambient take --engine=cesium|navara: without a
viewer they read the layer through the probe and move the engine camera;
the military rows/cluster are skipped where the engine has no military
layer. Cesium verdicts unchanged (sprites 9/9; enrich 9/2, the same E8/E10
failures as on 7c7422e2); Navara: sprites 7/7 (flights rows and 3D cluster),
enrich 9/2 (the same two). Task 10b, checklist item 7.
This commit is contained in:
German Khokhlov committed 2026-10-17 03:15:12 +04:00
1 parent 03a0ff3a01
commit 132a941e9a
5 files changed
+135 -46

No files matched your search

+25 -14
View File
@@ -66,9 +66,14 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const BASE_URL = getOpt('--url', 'http://localhost:4173');
// --engine=cesium|navara (phase 3): Navara reads the layer through the QA
// probe (window.__godsEyeView.layers.drawn) and moves the engine camera.
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/enrich-ambient' : `qa-shots/enrich-ambient-${ENGINE}`);
// Client-side bounds under test (flights.js constants):
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
@@ -289,7 +294,7 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
{ timeout: 60000, polling: 200 }
);
console.log(' App globals ready.');
@@ -298,6 +303,12 @@ async function main() {
await page.evaluate(() => {
window.__collectBillboards = function () {
const v = window.__godsEyeView.viewer;
// Without a viewer (Navara): what the flights layer draws, through
// the engine-neutral probe.
if (!v)
return (window.__godsEyeView.layers.drawn('flights') ?? [])
.filter((row) => !row.trackedModel)
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -321,12 +332,12 @@ async function main() {
// framing the fleet when the layer polls — otherwise the frustum test
// (correctly) skips off-screen planes.
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
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),
await page.evaluate(async ({ lat, lon, height }) => {
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const geo = await import('/src/geo/index.js');
camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
@@ -334,7 +345,7 @@ async function main() {
console.log('Priming straight-flight history through the render delay (30 s)...');
const primed = await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
const v = window.__godsEyeView.viewer ?? null;
window.__ENR.timeOffsetSec = -32;
await dm.setEnabled('flights', true);
const fl = dm.layers.get('flights').module;
@@ -456,7 +467,7 @@ async function main() {
console.log('\nE8 — next poll after enrichment');
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
const v = window.__godsEyeView.viewer ?? null;
await dm.layers.get('flights').module.update(v);
});
await sleep(500);
@@ -493,7 +504,7 @@ async function main() {
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
const dm = window.__godsEyeView.dataManager;
const v = window.__godsEyeView.viewer;
const v = window.__godsEyeView.viewer ?? null;
// Two fresh rows just north of the original one — still inside the
// top-down 20 km frame, so the sweep's frustum test keeps them.
// STATIONARY (speed 0): moving planes drift north out of the frustum at
@@ -522,7 +533,7 @@ async function main() {
// One more poll while exhausted — still nothing new.
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
});
await sleep(1000);
const exhausted = await page.evaluate(() => ({
@@ -544,7 +555,7 @@ async function main() {
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
await page.evaluate(async () => {
const dm = window.__godsEyeView.dataManager;
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
});
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
const resumed = await page.waitForFunction(
+57 -32
View File
@@ -53,9 +53,15 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const BASE_URL = getOpt('--url', 'http://localhost:4173');
// --engine=cesium|navara (phase 3): without a viewer the layer is read through
// the QA probe (window.__godsEyeView.layers.drawn) and the military checks are
// skipped where the engine has no military layer.
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
const APP_URL =
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
const HEADFUL = getFlag('--headful');
const SHOT_DIR = path.resolve('qa-shots/b5');
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/b5' : `qa-shots/b5-${ENGINE}`);
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
@@ -275,7 +281,7 @@ async function main() {
console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
{ timeout: 60000, polling: 200 }
);
console.log(' App globals ready.');
@@ -285,6 +291,10 @@ async function main() {
// Every billboard (image + alignedAxis + id) in every collection.
window.__collectBillboards = function () {
const v = window.__godsEyeView.viewer;
if (!v)
return (window.__godsEyeView.layers.drawn('flights') ?? [])
.filter((row) => !row.trackedModel)
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -304,6 +314,10 @@ async function main() {
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
window.__collectModels = function () {
const v = window.__godsEyeView.viewer;
if (!v)
return (window.__godsEyeView.layers.drawn('flights') ?? [])
.filter((row) => row.model && (row.trackedModel || !row.tracked))
.map(({ id, model }) => ({ id, ...model, radius: null }));
const out = [];
const walk = (coll) => {
const n = coll.length;
@@ -331,14 +345,14 @@ async function main() {
};
// 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;
window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
const geo = await import('/src/geo/index.js');
const { engine } = window.__godsEyeView;
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);
const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
const c = engine.projector.worldToWindow(pos);
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
}).join(' ');
};
@@ -348,26 +362,29 @@ async function main() {
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;
const v = window.__godsEyeView.viewer ?? null;
const hasMil = dm.layers.has('military');
window.__SPR.timeOffsetSec = -32;
await dm.setEnabled('flights', true);
await dm.setEnabled('military', true);
if (hasMil) await dm.setEnabled('military', true);
const fl = dm.layers.get('flights').module;
const mil = dm.layers.get('military').module;
const mil = hasMil ? dm.layers.get('military').module : null;
for (const off of [-24, -16, -8, 0]) {
window.__SPR.timeOffsetSec = off;
await fl.update(v);
await mil.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 };
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
return { fl: fl.getStats().count, mil: mil ? mil.getStats().count : null, hits: window.__SPR_HITS };
});
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
const hasMilitary = primed.mil !== null;
if (!hasMilitary) console.log(` [SKIP] the military rows — no military layer on ${ENGINE}`);
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; }
primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
`flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
// ========================================================================
// B2 — billboard glyph + scale per class (machine check)
@@ -384,7 +401,7 @@ async function main() {
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) {
for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
const bb = bbById.get(id);
const wantImg = aircraftIcon(klass);
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
@@ -392,24 +409,25 @@ async function main() {
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`);
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + (hasMilitary ? expectedMilitary.size : 0)} 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`);
const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
distinctGlyphs.size === wantGlyphs, `${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),
await page.evaluate(async ({ lat, lon, height }) => {
const camera = window.__godsEyeView.engine.camera;
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
const geo = await import('/src/geo/index.js');
camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, { lat, lon, height });
@@ -422,6 +440,7 @@ async function main() {
const zoomToRange = async (want) => {
await page.evaluate((want) => {
const v = window.__godsEyeView.viewer;
if (!v) return; // the engine follow keeps its own range (screenshots only)
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
@@ -441,10 +460,12 @@ async function main() {
// 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'))}`);
if (hasMilitary) {
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') });
@@ -507,6 +528,9 @@ async function main() {
// ========================================================================
// B3 + 3D screenshots — military cluster
// ========================================================================
if (!hasMilitary) {
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
} else {
console.log('\n3D — military cluster (jet.glb, per-class scale)');
await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
@@ -551,6 +575,7 @@ async function main() {
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
+3
View File
@@ -11,6 +11,8 @@
// plain { x, y, z }, screen a window point or null)
// screenOf(id, objectId) the window point of one object, or null
// selected(id) the layer's tracked/selected info, or null
// drawn(id) what the layer draws per object (glyph, model),
// as plain data, or null (layers with qaDrawn)
// following() { label, native } of engine.camera.following()
// movers() engine.movers.list()
// framesDrawn() frames drawn since the probe started
@@ -67,6 +69,7 @@ export function createQaProbe({ engine, dataManager, availability }) {
return row ? screenOf(row.position) : null;
},
selected: (layerId) => moduleOf(layerId)?.getTrackedInfo?.() ?? null,
drawn: (layerId) => moduleOf(layerId)?.qaDrawn?.() ?? null,
following() {
const handle = engine.camera.following();
if (!handle) return null;
+46
View File
@@ -809,6 +809,52 @@ export function createQueries({
return true;
},
/**
* What this layer draws, as plain data (QA: the engine-neutral probe,
* window.__godsEyeView.layers.drawn): per contact its glyph (image,
* scale, shown, screen rotation, alpha, position) and its 3D model, read
* from the renderer's handles — the same on every engine.
* @returns {Array<object>}
*/
qaDrawn() {
const out = [];
for (const [id, sprite] of flightState._billboards) {
const model = flightState._models.get(id);
out.push({
id,
tracked: id === flightState._trackedIcao,
image: sprite.image,
scale: sprite.scale,
width: sprite.width,
show: sprite.show,
rotation: sprite.rotation,
alpha: sprite.color?.alpha ?? null,
position: sprite.position
? {
x: sprite.position.x,
y: sprite.position.y,
z: sprite.position.z,
}
: null,
model: model
? { scale: model.scale, show: model.show, ready: model.ready }
: null,
});
}
const tracked = flightState._trackedModel;
if (flightState._trackedIcao && tracked)
out.push({
id: flightState._trackedIcao,
trackedModel: true,
model: {
scale: tracked.scale,
show: tracked.show,
ready: tracked.ready,
},
});
return out;
},
/**
* Stop tracking the currently followed aircraft (no-op if none).
* @returns {boolean} Always true.
+4
View File
@@ -291,6 +291,10 @@ test('the flights logic draws and follows on the Navara renderer (fake engine)',
'abc123',
'def456',
]);
// What it draws, as the QA probe reads it (the same on every engine).
const drawn = layer.qaDrawn();
assert.deepEqual(drawn.map((row) => row.id).sort(), ['abc123', 'def456']);
assert.ok(drawn.every((row) => row.show && row.image.startsWith('data:')));
// A click on a contact selects it; the engine follows with its label.
engine.fake.script('pick', () => ({ id: 'def456' }));
engine.fake.script('drillPick', () => [{ id: 'def456' }]);