From 132a941e9a297cb7fa4f4a6ccf308dd84050f5d9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 03:15:12 +0400 Subject: [PATCH] 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. --- scripts/qa-enrich-ambient.mjs | 39 +++++++----- scripts/qa-sprites-b5.mjs | 89 ++++++++++++++++++---------- src/app/qaProbe.js | 3 + src/layers/flights/queries.js | 46 ++++++++++++++ src/layers/flights/renderer.test.mjs | 4 ++ 5 files changed, 135 insertions(+), 46 deletions(-) diff --git a/scripts/qa-enrich-ambient.mjs b/scripts/qa-enrich-ambient.mjs index abdfddb9d..3a6cd8ad5 100644 --- a/scripts/qa-enrich-ambient.mjs +++ b/scripts/qa-enrich-ambient.mjs @@ -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( diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index a70eb1b2d..68351f5ef 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -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 diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js index 9e7bc25a6..66d2aecef 100644 --- a/src/app/qaProbe.js +++ b/src/app/qaProbe.js @@ -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; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 507fc2567..8a9136f02 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -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} + */ + 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. diff --git a/src/layers/flights/renderer.test.mjs b/src/layers/flights/renderer.test.mjs index c19115aac..194b2ca33 100644 --- a/src/layers/flights/renderer.test.mjs +++ b/src/layers/flights/renderer.test.mjs @@ -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' }]);