diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4a94003aa..de886bb04 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/qa-label-readability.mjs b/scripts/qa-label-readability.mjs index b7d17752c..f3f8219d6 100644 --- a/scripts/qa-label-readability.mjs +++ b/scripts/qa-label-readability.mjs @@ -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 || '?'}]`, ); } diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 8d26dcb30..a631b598b 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -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); } diff --git a/scripts/qa-overlay-baseline.mjs b/scripts/qa-overlay-baseline.mjs index 57dc1fcd7..72682c089 100644 --- a/scripts/qa-overlay-baseline.mjs +++ b/scripts/qa-overlay-baseline.mjs @@ -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'); diff --git a/scripts/qa-perf.mjs b/scripts/qa-perf.mjs index 6aa5f9e89..86b40ba40 100644 --- a/scripts/qa-perf.mjs +++ b/scripts/qa-perf.mjs @@ -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); diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js new file mode 100644 index 000000000..f8f351062 --- /dev/null +++ b/src/app/layers/qaSources.js @@ -0,0 +1,71 @@ +// QA fixture sources (only with allowQaRegistration, i.e. the dev server): +// `?qaFeed=flights:synthetic:10000,vessels:fixture` replaces those layers' +// live sources with seeded synthetic answers (src/bench/w1Feeds.js) served +// through the live sources' own parsers, so the layers see the same snapshot +// shape. Times are relative to now, so the objects stay fresh. +// +// flights:synthetic[:count] OpenSky states, `count` aircraft (8 000) +// vessels:fixture 12 000 AIS rows, or those within an area + +import { + createFlightSource, + createVesselSource, +} from '../../sources/live/standalone.js'; +import { syntheticFlights, syntheticVessels } from '../../bench/w1Feeds.js'; + +const KINDS = Object.freeze({ + flights: new Set(['synthetic']), + vessels: new Set(['fixture']), +}); + +/** The qaFeed entries of a location search, or a TypeError. */ +export function parseQaFeed(search = '') { + const value = new URLSearchParams(search).get('qaFeed'); + if (!value) return []; + return value.split(',').map((item) => { + const [layer, kind, rawCount] = item.split(':'); + const count = rawCount === undefined ? null : Number(rawCount); + if ( + !KINDS[layer]?.has(kind) || + (count !== null && !(Number.isInteger(count) && count > 0)) + ) + throw new TypeError( + `qaFeed: unknown entry "${item}" (flights:synthetic[:count], vessels:fixture)`, + ); + return { layer, kind, count }; + }); +} + +const json = (payload) => + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +/** Source overrides for createStandaloneLayerSources. */ +export function createQaSources(entries, { now = () => Date.now() } = {}) { + const sources = {}; + for (const { layer, count } of entries) { + if (layer === 'flights') + sources.flights = createFlightSource({ + now, + fetchImpl: async () => + json(syntheticFlights({ nowMs: now(), count: count ?? 8000 })), + }); + if (layer === 'vessels') + sources.vessels = createVesselSource({ + fetchImpl: async (url) => { + const params = new URL(url).searchParams; + const area = params.has('lat') + ? { + lat: Number(params.get('lat')), + lon: Number(params.get('lon')), + radiusKm: Number(params.get('radius_km')), + } + : null; + return json(syntheticVessels({ nowMs: now(), area })); + }, + }); + } + return sources; +} diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs new file mode 100644 index 000000000..0685be701 --- /dev/null +++ b/src/app/layers/qaSources.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createQaSources, parseQaFeed } from './qaSources.js'; + +test('qaFeed names fixture sources per layer', () => { + assert.deepEqual( + parseQaFeed('?qaFeed=flights:synthetic:10000,vessels:fixture'), + [ + { layer: 'flights', kind: 'synthetic', count: 10000 }, + { layer: 'vessels', kind: 'fixture', count: null }, + ], + ); + assert.deepEqual(parseQaFeed('?engine=navara'), []); + assert.throws(() => parseQaFeed('?qaFeed=flights:live'), /qaFeed/); + assert.throws(() => parseQaFeed('?qaFeed=weather:fixture'), /qaFeed/); + assert.throws(() => parseQaFeed('?qaFeed=flights:synthetic:-3'), /qaFeed/); +}); + +test('fixture sources answer with the live sources’ snapshot shape', async () => { + const now = Date.UTC(2026, 9, 16, 12); + const sources = createQaSources( + parseQaFeed('?qaFeed=flights:synthetic:500,vessels:fixture'), + { now: () => now }, + ); + const flights = await sources.flights.getSnapshot({}); + assert.equal(flights.records.length, 500); + assert.ok( + flights.records.every((record) => Number.isFinite(record.latitude)), + ); + // Seeded: the same aircraft every time. + const again = await sources.flights.getSnapshot({}); + assert.deepEqual( + again.records.map(({ id }) => id), + flights.records.map(({ id }) => id), + ); + const vessels = await sources.vessels.getSnapshot({ maxRows: 12000 }); + assert.equal(vessels.records.length, 12000); + const near = await sources.vessels.getSnapshot({ + maxRows: 12000, + area: { lat: 40.65724, lon: -73.78, radiusKm: 10 }, + }); + assert.ok(near.records.length > 10 && near.records.length < 12000); + assert.deepEqual(createQaSources([]), {}); +}); diff --git a/src/app/qaProbe.js b/src/app/qaProbe.js new file mode 100644 index 000000000..9e7bc25a6 --- /dev/null +++ b/src/app/qaProbe.js @@ -0,0 +1,82 @@ +// The engine-neutral QA probe: window.__godsEyeView.layers. QA scripts ask +// it what a layer shows and where, the same way on Cesium and Navara, +// instead of reading Cesium objects. Read-only apart from enable/disable, +// which go through the layer manager as a user toggle would. +// +// list() [{ id, name, enabled, available }] (the catalog) +// unavailable() [{ id, name }] layers this engine leaves out +// available(id) whether this engine can show layer `id` +// enable(id) / disable(id) → Promise +// objects(id, max?) [{ id, label, position, screen }] (position a +// 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 +// following() { label, native } of engine.camera.following() +// movers() engine.movers.list() +// framesDrawn() frames drawn since the probe started + +const plain = (position) => + position ? { x: position.x, y: position.y, z: position.z } : null; + +/** + * @param {{ + * engine: import('../globe/contract.js').GlobeEngine, + * dataManager: object, + * availability: import('./layers/renderers.js').LayerAvailability, + * }} options + */ +export function createQaProbe({ engine, dataManager, availability }) { + let frames = 0; + const removeFrames = engine.frames.postRender.addEventListener(() => { + frames++; + }); + const moduleOf = (layerId) => dataManager.layers.get(layerId)?.module; + const screenOf = (position) => { + if (!position || !engine.projector.horizonVisible(position)) return null; + const screen = engine.projector.worldToWindow(position); + return screen ? { x: screen.x, y: screen.y } : null; + }; + const objects = (layerId, max = 500) => { + const rows = moduleOf(layerId)?.getAllPositions?.(max) ?? []; + return rows.map((row) => ({ + id: row.id, + label: row.label ?? null, + position: plain(row.position), + screen: screenOf(row.position), + })); + }; + return { + list: () => + dataManager.getAll().map(({ id, name, enabled }) => ({ + id, + name, + enabled: Boolean(enabled), + available: availability.isAvailable(id), + })), + unavailable: () => availability.unavailable(), + available: (layerId) => availability.isAvailable(layerId), + enable: (layerId) => + dataManager.setEnabled(layerId, true, { origin: 'user' }), + disable: (layerId) => + dataManager.setEnabled(layerId, false, { origin: 'user' }), + objects, + screenOf(layerId, objectId) { + const row = (moduleOf(layerId)?.getAllPositions?.(1_000_000) ?? []).find( + ({ id }) => String(id) === String(objectId), + ); + return row ? screenOf(row.position) : null; + }, + selected: (layerId) => moduleOf(layerId)?.getTrackedInfo?.() ?? null, + following() { + const handle = engine.camera.following(); + if (!handle) return null; + return { + label: handle.label?.id ?? null, + native: handle.native?.id ?? null, + }; + }, + movers: () => engine.movers.list(), + framesDrawn: () => frames, + destroy: () => removeFrames(), + }; +} diff --git a/src/app/qaProbe.test.mjs b/src/app/qaProbe.test.mjs new file mode 100644 index 000000000..a247f70e0 --- /dev/null +++ b/src/app/qaProbe.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as geo from '../geo/index.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createQaProbe } from './qaProbe.js'; +import { + createLayerAvailability, + createRendererRegistry, +} from './layers/renderers.js'; + +function fakeManager(modules) { + const enabled = new Set(); + return { + layers: new Map(modules.map((module) => [module.id, { module }])), + getAll: () => + modules.map(({ id, name }) => ({ id, name, enabled: enabled.has(id) })), + isEnabled: (id) => enabled.has(id), + async setEnabled(id, value) { + if (value) enabled.add(id); + else enabled.delete(id); + return true; + }, + }; +} + +test('the probe lists, toggles and reads layers through the engine', async () => { + const engine = createFakeEngine({ kind: 'navara' }); + const position = geo.cartesian3.fromDegrees(-73.78, 40.64, 1000); + const flights = { + id: 'flights', + name: 'Live Flights', + getAllPositions: () => [{ id: 'ae01ce', label: 'UAL1', position }], + getTrackedInfo: () => ({ icao24: 'ae01ce' }), + }; + const manager = fakeManager([flights]); + const registry = createRendererRegistry(); + registry.register('navara', { flights: () => ({}) }); + const probe = createQaProbe({ + engine, + dataManager: manager, + availability: createLayerAvailability('navara', { registry }), + }); + assert.deepEqual(probe.list(), [ + { id: 'flights', name: 'Live Flights', enabled: false, available: true }, + ]); + assert.equal(probe.available('flights'), true); + assert.equal(probe.available('satellites'), false); + assert.deepEqual( + probe + .unavailable() + .map(({ id }) => id) + .includes('satellites'), + true, + ); + assert.equal(await probe.enable('flights'), true); + assert.equal(manager.isEnabled('flights'), true); + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-73.78, 40.64, 50_000), + }); + engine.fake.script('worldToWindow', () => ({ x: 640, y: 400 })); + const [object] = probe.objects('flights'); + assert.equal(object.id, 'ae01ce'); + assert.deepEqual( + [object.position.x, object.position.y, object.position.z], + [position.x, position.y, position.z], + ); + assert.deepEqual(object.screen, { x: 640, y: 400 }); + assert.deepEqual(probe.screenOf('flights', 'ae01ce'), { x: 640, y: 400 }); + assert.equal(probe.screenOf('flights', 'nope'), null); + assert.deepEqual(probe.selected('flights'), { icao24: 'ae01ce' }); + assert.deepEqual(probe.objects('unknown'), []); + // Follow, movers and frames come from the engine. + assert.equal(probe.following(), null); + engine.camera.follow({ + position: () => position, + viewFrom: { x: 0, y: -1000, z: 300 }, + label: { + id: 'flights:ae01ce', + model: { title: 'UAL1' }, + position: () => position, + }, + }); + assert.deepEqual(probe.following(), { + label: 'flights:ae01ce', + native: null, + }); + engine.movers.createSet({ + id: 'fleet', + kind: 'point', + capacity: 4, + point: { sizePx: 4 }, + }); + assert.deepEqual(probe.movers(), [ + { id: 'fleet', kind: 'point', count: 0, capacity: 4 }, + ]); + const before = probe.framesDrawn(); + engine.fake.frame(); + engine.fake.frame(); + assert.equal(probe.framesDrawn() - before, 2); + assert.equal(await probe.disable('flights'), true); + assert.equal(manager.isEnabled('flights'), false); + probe.destroy(); +}); diff --git a/src/bench/w1Feeds.js b/src/bench/w1Feeds.js new file mode 100644 index 000000000..03f4b2bfc --- /dev/null +++ b/src/bench/w1Feeds.js @@ -0,0 +1,162 @@ +// Seeded synthetic feed answers: the global /api/flights snapshot (OpenSky +// states), 12 000 AIS vessels on /api/vessels (with and without an area) and +// the local receivers' 1090 MHz and 978 MHz UAT aircraft near JFK, timed +// relative to one instant, so every run sees the same objects at the same +// places. Pure: Node and browser, no I/O. Used by the W1 baseline recordings +// (src/tooling/w1Feeds.js) and the app's QA fixture sources +// (src/app/layers/qaSources.js). + +import { createSyntheticFlights } from './syntheticFlights.js'; + +/** The replay clock of the synthetic recordings: 2026-10-16T12:00:00Z. */ +export const W1_FROZEN_MS = Date.UTC(2026, 9, 16, 12); +const JFK = { lat: 40.64, lng: -73.78 }; + +/** Small deterministic PRNG (as syntheticFlights). */ +function mulberry32(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const hex = (value, digits) => value.toString(16).padStart(digits, '0'); + +/** An OpenSky states payload: `count` aircraft, 2 000 of them near JFK. */ +export function syntheticFlights({ + nowMs = W1_FROZEN_MS, + count = 8_000, + seed = 11, +} = {}) { + const fleet = createSyntheticFlights({ + count, + seed, + near: { ...JFK, radiusM: 60_000, count: Math.min(2_000, count) }, + }); + const random = mulberry32(seed + 1); + const nowSec = Math.floor(nowMs / 1000); + const states = []; + for (let index = 0; index < count; index++) { + const age = Math.floor(random() * 10); + states.push([ + hex(0xa00000 + index, 6), + `GEV${String(index).padStart(4, '0')}`, + 'United States', + nowSec - age, + nowSec - Math.min(age, 2), + Number(fleet.lon[index].toFixed(5)), + Number(fleet.lat[index].toFixed(5)), + Math.round(fleet.alt[index]), + false, + Number(fleet.speed[index].toFixed(1)), + Number(fleet.heading[index].toFixed(1)), + 0, + null, + Math.round(fleet.alt[index]) + 30, + null, + false, + 0, + index % 7 === 0 ? 1 : 3, + ]); + } + return { time: nowSec, states }; +} + +const PORTS = [ + { lat: 40.6, lon: -74.02, spread: 0.25 }, // New York harbour + { lat: 51.95, lon: 4.1, spread: 0.4 }, // Rotterdam + { lat: 29.7, lon: -95.0, spread: 0.3 }, // Houston + { lat: 1.26, lon: 103.8, spread: 0.4 }, // Singapore +]; + +function allVessels(nowMs, seed) { + const random = mulberry32(seed); + const nowSec = Math.floor(nowMs / 1000); + const rows = []; + for (let index = 0; index < 12_000; index++) { + const port = index < 4_000 ? PORTS[index % PORTS.length] : null; + const lat = port + ? port.lat + (random() - 0.5) * port.spread + : random() * 120 - 60; + const lon = port + ? port.lon + (random() - 0.5) * port.spread * 1.5 + : random() * 360 - 180; + rows.push({ + mmsi: String(200_000_000 + index), + lat: Number(lat.toFixed(5)), + lon: Number(lon.toFixed(5)), + name: `GEV VESSEL ${index}`, + imo: '', + type: ['Cargo', 'Tanker', 'Passenger', 'Fishing', 'Tug'][index % 5], + destination: '', + speed: Number((random() * 18).toFixed(1)), + course: Math.round(random() * 360), + heading: Math.round(random() * 360), + last_position_epoch: nowSec - Math.floor(random() * 120), + }); + } + return rows; +} + +const kmBetween = (a, b) => { + const toRad = Math.PI / 180; + const dLat = (b.lat - a.lat) * toRad; + const dLon = (b.lon - a.lon) * toRad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * 6371 * Math.asin(Math.sqrt(h)); +}; + +/** The /api/vessels answer: 12 000 rows, or those within `area`. */ +export function syntheticVessels({ + nowMs = W1_FROZEN_MS, + seed = 21, + area = null, +} = {}) { + let rows = allVessels(nowMs, seed); + if (area) rows = rows.filter((row) => kmBetween(area, row) <= area.radiusKm); + return { rows, newestPositionAt: nowMs }; +} + +/** The /api/local-receivers/aircraft answer: 1090 MHz and 978 MHz UAT aircraft. */ +export function syntheticLocalReceivers({ + nowMs = W1_FROZEN_MS, + seed = 31, +} = {}) { + const random = mulberry32(seed); + const records = []; + for (let index = 0; index < 40; index++) { + const uat = index % 4 === 0; + records.push({ + icao: hex(0xad0000 + index, 6), + callsign: `LCL${index}`, + lat: Number((JFK.lat + (random() - 0.5) * 0.8).toFixed(5)), + lon: Number((JFK.lng + (random() - 0.5) * 1.0).toFixed(5)), + altitudeFt: Math.round(1_000 + random() * 20_000), + groundSpeedKt: Math.round(80 + random() * 300), + trackDeg: Math.round(random() * 360), + verticalRateFpm: 0, + onGround: false, + category: uat ? 'A1' : 'A3', + lastPositionAt: nowMs - Math.floor(random() * 2_000), + lastMessageAt: nowMs - Math.floor(random() * 500), + messageCount: 50, + band: uat ? '978' : '1090', + source: 'feed', + }); + } + return { + configured: true, + generatedAt: nowMs, + feeds: [ + { band: '1090', label: '1090 MHz', status: 'live', aircraft: 30 }, + { band: '978', label: '978 MHz UAT', status: 'live', aircraft: 10 }, + ], + records, + }; +} diff --git a/src/standalone/application.js b/src/standalone/application.js index 40c5d4d04..bda125fa2 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -8,6 +8,7 @@ import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; import { resolveEngineKind } from '../app/engineChoice.js'; +import { createQaProbe } from '../app/qaProbe.js'; // The existing controls and layer catalog contain page-scoped state. let constructed = false; @@ -65,9 +66,18 @@ export function createStandaloneApplication({ // The catalog of this engine: on Navara only the layers with a Navara // renderer (src/app/layers/renderers.js; none yet), the others' state // kept for links. + // QA fixture sources (?qaFeed=…) on the dev server only. + const search = globalThis.location?.search ?? ''; + const qaSources = + allowQaRegistration && /[?&]qaFeed=/.test(search) + ? await import('../app/layers/qaSources.js').then( + ({ createQaSources, parseQaFeed }) => + createQaSources(parseQaFeed(search)), + ) + : {}; catalog = createStandaloneCatalog({ engineKind, - sources, + sources: { ...sources, ...qaSources }, streetLevelProviders, nepalBoundaryResolver: (signal) => scene.operations.annotationResolver.resolveRegionRingForQuery( @@ -91,7 +101,28 @@ export function createStandaloneApplication({ }), createData: (context) => createStandaloneData({ ...context, allowQaRegistration, catalog }), - createTools: (context) => - createStandaloneTools({ ...context, loadingScreen, placeSearch, voice }), + createTools: async (context) => { + const tools = await createStandaloneTools({ + ...context, + loadingScreen, + placeSearch, + voice, + }); + // The engine-neutral QA probe on the debug handle (src/app/qaProbe.js). + const debug = globalThis.window?.__godsEyeView; + if (debug) { + const probe = createQaProbe({ + engine: context.scene.engine, + dataManager: context.data.dataManager, + availability: catalog.availability, + }); + debug.layers = probe; + context.defer(() => { + probe.destroy(); + if (debug.layers === probe) delete debug.layers; + }); + } + return tools; + }, }); }