mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
834d34296f
commit
ec7dcc944e
11 files changed
+721
-93
No files matched your search
@@ -1101,5 +1101,11 @@
|
|||||||
"src/app/layers/renderers.js",
|
"src/app/layers/renderers.js",
|
||||||
"src/app/layers/renderers.test.mjs",
|
"src/app/layers/renderers.test.mjs",
|
||||||
"src/data/lifecycleEngine.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"
|
||||||
]
|
]
|
||||||
@@ -4,7 +4,11 @@
|
|||||||
* legibility over bright ground.
|
* legibility over bright ground.
|
||||||
*
|
*
|
||||||
* Run:
|
* 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
|
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||||
* synthetic contact field for the live pollers, so a `before` run and an
|
* 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;
|
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');
|
const TAG = getOpt('--tag', 'after');
|
||||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||||
const SCENE_FILTER = getOpt('--scenes', '')
|
const SCENE_FILTER = getOpt('--scenes', '')
|
||||||
@@ -205,11 +214,8 @@ async function settle(page) {
|
|||||||
while (Date.now() - started < SETTLE_MAX_MS) {
|
while (Date.now() - started < SETTLE_MAX_MS) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
const state = await page.evaluate(() => {
|
const state = await page.evaluate(() => {
|
||||||
const { viewer, tileset } = window.__godsEyeView;
|
const { engine } = window.__godsEyeView;
|
||||||
return {
|
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
|
||||||
tilesLoaded: tileset?.tilesLoaded !== false,
|
|
||||||
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
const settled = state.tilesLoaded && state.pending === 0;
|
const settled = state.tilesLoaded && state.pending === 0;
|
||||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
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.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||||
{ timeout: 60_000, polling: 100 },
|
{ timeout: 60_000, polling: 100 },
|
||||||
);
|
);
|
||||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
// 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) {
|
for (const scene of selected) {
|
||||||
process.stdout.write(` ${scene.id} … `);
|
process.stdout.write(` ${scene.id} … `);
|
||||||
await page.evaluate((spec) => {
|
const fieldShown = await page.evaluate(async (spec) => {
|
||||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
|
||||||
viewer.camera.cancelFlight();
|
const geo = await import('/src/geo/index.js');
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
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(
|
destination: Cartesian3.fromDegrees(
|
||||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
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
|
// the frame comes back with two labels instead of a field. Building
|
||||||
// straight off the camera basis guarantees every contact lands inside
|
// straight off the camera basis guarantees every contact lands inside
|
||||||
// the keyhole, at a known depth, in both the before and after runs.
|
// the keyhole, at a known depth, in both the before and after runs.
|
||||||
const camera = viewer.camera;
|
const camera = engine.camera.pose();
|
||||||
const frustum = camera.frustum;
|
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
|
||||||
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
|
const halfWidth = halfHeight * (camera.aspectRatio || 1.6);
|
||||||
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
|
|
||||||
const basis = {
|
const basis = {
|
||||||
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
||||||
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.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 pointAt = (ndcX, ndcY, distance) => {
|
||||||
const sx = ndcX * halfWidth * distance;
|
const sx = ndcX * halfWidth * distance;
|
||||||
const sy = ndcY * halfHeight * distance;
|
const sy = ndcY * halfHeight * distance;
|
||||||
return new Cartesian3(
|
return Cartesian3(
|
||||||
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
||||||
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
||||||
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
||||||
@@ -347,6 +356,9 @@ async function main() {
|
|||||||
? { satellites: build('s'), flights: [], military: [] }
|
? { satellites: build('s'), flights: [], military: [] }
|
||||||
: { flights: build('f'), military: [], satellites: [] };
|
: { flights: build('f'), military: [], satellites: [] };
|
||||||
window.__LABEL_EVIDENCE = assignment;
|
window.__LABEL_EVIDENCE = assignment;
|
||||||
|
const fieldLayers = Object.keys(assignment).filter(
|
||||||
|
(layerId) => assignment[layerId].length,
|
||||||
|
);
|
||||||
for (const layerId of Object.keys(assignment)) {
|
for (const layerId of Object.keys(assignment)) {
|
||||||
const entry = dataManager.layers.get(layerId);
|
const entry = dataManager.layers.get(layerId);
|
||||||
if (entry?.module) {
|
if (entry?.module) {
|
||||||
@@ -362,6 +374,7 @@ async function main() {
|
|||||||
densityPct: 100,
|
densityPct: 100,
|
||||||
allocationStrategy: 'elastic',
|
allocationStrategy: 'elastic',
|
||||||
});
|
});
|
||||||
|
return fieldLayers.every((layerId) => layers.available(layerId));
|
||||||
}, scene);
|
}, scene);
|
||||||
|
|
||||||
const converged = await settle(page);
|
const converged = await settle(page);
|
||||||
@@ -399,10 +412,11 @@ async function main() {
|
|||||||
const diagnostics = await page.evaluate(
|
const diagnostics = await page.evaluate(
|
||||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||||
);
|
);
|
||||||
results.push({ scene: scene.id, converged, ...stats });
|
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
|
||||||
console.log(
|
console.log(
|
||||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||||
|
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
|
||||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-19
@@ -3,7 +3,12 @@
|
|||||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||||
*
|
*
|
||||||
* Run:
|
* 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.
|
* 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
|
* 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;
|
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 APP_ORIGIN = new URL(APP_URL).origin;
|
||||||
const HEADFUL = getFlag('--headful');
|
const HEADFUL = getFlag('--headful');
|
||||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||||
@@ -74,6 +84,8 @@ function findChromeExecutable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
let skippedChecks = 0;
|
||||||
|
let skippedField = false;
|
||||||
function record(name, ok, detail) {
|
function record(name, ok, detail) {
|
||||||
results.push({ name, ok, detail });
|
results.push({ name, ok, detail });
|
||||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
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.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||||
{ timeout: 60000, polling: 100 },
|
{ timeout: 60000, polling: 100 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -279,7 +291,7 @@ async function main() {
|
|||||||
// Let that callback start, then cancel it before establishing the global
|
// Let that callback start, then cancel it before establishing the global
|
||||||
// measurement camera so startup motion cannot invalidate the sample.
|
// measurement camera so startup motion cannot invalidate the sample.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
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 stateChecks = await page.evaluate((storageKey) => {
|
||||||
const manager = window.__godsEyeView.styleManager;
|
const manager = window.__godsEyeView.styleManager;
|
||||||
@@ -305,10 +317,32 @@ async function main() {
|
|||||||
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
|
const fieldLayers = ['flights', 'military', 'satellites'];
|
||||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
const missingLayers = await page.evaluate(
|
||||||
viewer.camera.cancelFlight();
|
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
|
||||||
const Cartesian3 = viewer.camera.position.constructor;
|
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 field = { flights: [], military: [], satellites: [] };
|
||||||
const layerIds = Object.keys(field);
|
const layerIds = Object.keys(field);
|
||||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||||
@@ -339,10 +373,15 @@ async function main() {
|
|||||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer.camera.setView({
|
// The global camera, orbiting west to east (the old rotateRight about
|
||||||
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
|
// 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 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
});
|
});
|
||||||
|
placeCamera();
|
||||||
styleManager.setDetection({
|
styleManager.setDetection({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
densityPct: 100,
|
densityPct: 100,
|
||||||
@@ -359,13 +398,14 @@ async function main() {
|
|||||||
let lastOrbitAt = performance.now();
|
let lastOrbitAt = performance.now();
|
||||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
|
||||||
lastOrbitAt = now;
|
lastOrbitAt = now;
|
||||||
viewer.scene.requestRender();
|
placeCamera();
|
||||||
|
engine.render.request('qa-labels');
|
||||||
}, 100);
|
}, 100);
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
const frameNumber = window.__LABEL_QA_FRAME;
|
||||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||||
window.__LABEL_QA.frames.push({
|
window.__LABEL_QA.frames.push({
|
||||||
@@ -395,7 +435,7 @@ async function main() {
|
|||||||
observationCount: Number(dataset.observationCount),
|
observationCount: Number(dataset.observationCount),
|
||||||
selectedCount: Number(dataset.selectedCount),
|
selectedCount: Number(dataset.selectedCount),
|
||||||
fadingCount: Number(dataset.fadingCount),
|
fadingCount: Number(dataset.fadingCount),
|
||||||
cameraHeight: viewer.camera.positionCartographic.height,
|
cameraHeight: engine.camera.height(),
|
||||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||||
@@ -420,12 +460,11 @@ async function main() {
|
|||||||
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
||||||
await waitForSettledSyntheticField(page);
|
await waitForSettledSyntheticField(page);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
|
||||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||||
window.__LABEL_QA.solve = [];
|
window.__LABEL_QA.solve = [];
|
||||||
window.__LABEL_QA.frames = [];
|
window.__LABEL_QA.frames = [];
|
||||||
window.__LABEL_QA.lastRevision = revision;
|
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();
|
window.__LABEL_QA.startedAt = performance.now();
|
||||||
});
|
});
|
||||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||||
@@ -460,12 +499,11 @@ async function main() {
|
|||||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||||
);
|
);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
|
||||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||||
window.__LABEL_QA.solve = [];
|
window.__LABEL_QA.solve = [];
|
||||||
window.__LABEL_QA.frames = [];
|
window.__LABEL_QA.frames = [];
|
||||||
window.__LABEL_QA.lastRevision = revision;
|
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();
|
window.__LABEL_QA.startedAt = performance.now();
|
||||||
});
|
});
|
||||||
const normalSampling = await waitForConclusiveSamples(page, {
|
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)`);
|
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
if (skippedField) summarize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summarize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize() {
|
||||||
const failures = results.filter((result) => !result.ok);
|
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);
|
if (failures.length) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
* node scripts/qa-overlay-baseline.mjs --screenshots-dir /tmp/overlay-shots
|
* 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 --hardware-gpu --headful
|
||||||
* node scripts/qa-overlay-baseline.mjs --dist-dir /tmp/gev-dist
|
* 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';
|
import fs from 'node:fs';
|
||||||
@@ -263,6 +269,9 @@ function printScene(result) {
|
|||||||
}
|
}
|
||||||
if (result.entityInventory) {
|
if (result.entityInventory) {
|
||||||
const totals = result.entityInventory.totals;
|
const totals = result.entityInventory.totals;
|
||||||
|
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}`);
|
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
|
||||||
}
|
}
|
||||||
for (const phaseName of ['motion', 'rest']) {
|
for (const phaseName of ['motion', 'rest']) {
|
||||||
@@ -345,33 +354,29 @@ async function installPageInstrumentation(page) {
|
|||||||
async function waitForApp(page) {
|
async function waitForApp(page) {
|
||||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const { engine, viewer } = window.__godsEyeView;
|
||||||
try { viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
try { engine.camera.cancelFlight(); } catch { /* no flight */ }
|
||||||
try { viewer.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
|
try { viewer?.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
|
||||||
});
|
});
|
||||||
await sleep(1_250);
|
await sleep(1_250);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCamera(page, camera) {
|
async function setCamera(page, camera) {
|
||||||
if (!camera) return;
|
if (!camera) return;
|
||||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
await page.evaluate(async ([lon, lat, height, heading, pitch]) => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
const { engine, viewer } = window.__godsEyeView;
|
||||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
const geo = await import('/src/geo/index.js');
|
||||||
viewer.camera.cancelFlight?.();
|
engine.camera.cancelFlight();
|
||||||
viewer.scene.tweens?.removeAll?.();
|
viewer?.scene.tweens?.removeAll?.();
|
||||||
viewer.camera.setView({
|
engine.camera.setView({
|
||||||
destination: ellipsoid.cartographicToCartesian({
|
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||||
longitude: lon * Math.PI / 180,
|
|
||||||
latitude: lat * Math.PI / 180,
|
|
||||||
height,
|
|
||||||
}),
|
|
||||||
orientation: { heading, pitch, roll: 0 },
|
orientation: { heading, pitch, roll: 0 },
|
||||||
});
|
});
|
||||||
viewer.scene.requestRender?.();
|
engine.render.request('qa-overlay-baseline');
|
||||||
}, camera);
|
}, camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +416,19 @@ async function waitForLayer(page, layerId) {
|
|||||||
|
|
||||||
async function readEntityInventory(page) {
|
async function readEntityInventory(page) {
|
||||||
return page.evaluate(() => {
|
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 time = viewer.clock.currentTime;
|
||||||
const collections = [];
|
const collections = [];
|
||||||
const totals = {
|
const totals = {
|
||||||
@@ -605,7 +622,7 @@ async function prepareTrackedFlight(page, cockpit) {
|
|||||||
});
|
});
|
||||||
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return 'Civil tracking did not create a tracked entity';
|
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';
|
if (!selected) return 'Mission roster did not expose a selectable mission';
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return 'Mission selection did not reach the Cesium selected entity';
|
return 'Mission selection did not reach the Cesium selected entity';
|
||||||
}
|
}
|
||||||
@@ -647,6 +664,12 @@ async function prepareMission(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function prepareScene(page, scene) {
|
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);
|
if (scene.camera) await setCamera(page, scene.camera);
|
||||||
const layerActivations = await activateLayers(page, scene.layers);
|
const layerActivations = await activateLayers(page, scene.layers);
|
||||||
let skipReason = null;
|
let skipReason = null;
|
||||||
@@ -678,7 +701,28 @@ async function readCanvasSummary(page) {
|
|||||||
|
|
||||||
async function samplePhase(page, moving) {
|
async function samplePhase(page, moving) {
|
||||||
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
|
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;
|
const instrumentation = window.__overlayBaselineInstrumentation;
|
||||||
instrumentation?.reset?.();
|
instrumentation?.reset?.();
|
||||||
const intervals = [];
|
const intervals = [];
|
||||||
@@ -690,10 +734,11 @@ async function samplePhase(page, moving) {
|
|||||||
lastFrameAt = now;
|
lastFrameAt = now;
|
||||||
const elapsed = now - startedAt;
|
const elapsed = now - startedAt;
|
||||||
if (movingCamera) {
|
if (movingCamera) {
|
||||||
viewer.camera.rotateRight(0.00035);
|
const pose = engine.camera.pose();
|
||||||
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
|
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();
|
if (elapsed >= durationMs) resolve();
|
||||||
else requestAnimationFrame(step);
|
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 DPR = Number(getOpt('--dpr', '1'));
|
||||||
const JSON_PATH = getOpt('--json');
|
const JSON_PATH = getOpt('--json');
|
||||||
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
||||||
|
|||||||
+49
-27
@@ -68,21 +68,35 @@
|
|||||||
* "world-overlay honours occluder churn as paint work" in
|
* "world-overlay honours occluder churn as paint work" in
|
||||||
* the project roadmap.
|
* 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
|
* Requires a running dev server. Headless; flags disable occlusion
|
||||||
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
|
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
|
||||||
*/
|
*/
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
const argv = process.argv;
|
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 = [];
|
const results = [];
|
||||||
|
let skipped = 0;
|
||||||
function check(name, pass, detail) {
|
function check(name, pass, detail) {
|
||||||
results.push({ name, pass });
|
results.push({ name, pass });
|
||||||
const tag = pass ? 'PASS' : 'FAIL';
|
const tag = pass ? 'PASS' : 'FAIL';
|
||||||
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
|
console.log(` [${tag}] ${name}${detail ? ` — ${JSON.stringify(detail)}` : ''}`);
|
||||||
}
|
}
|
||||||
|
function skip(name, reason) {
|
||||||
|
skipped += 1;
|
||||||
|
console.log(` [SKIP] ${name} — ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: 'new',
|
headless: 'new',
|
||||||
@@ -103,20 +117,18 @@ try {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 1440, height: 860 });
|
await page.setViewport({ width: 1440, height: 860 });
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
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.
|
// Boot flyTo + tile warm + all deferred init.
|
||||||
await new Promise((r) => setTimeout(r, 15_000));
|
await new Promise((r) => setTimeout(r, 15_000));
|
||||||
|
|
||||||
// Park deterministically and disable every layer.
|
// Park deterministically and disable every layer.
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
const v = gev.viewer;
|
const geo = await import('/src/geo/index.js');
|
||||||
v.camera.cancelFlight();
|
gev.engine.camera.cancelFlight();
|
||||||
const ell = v.scene.globe.ellipsoid;
|
gev.engine.camera.setView({
|
||||||
v.camera.setView({
|
destination: geo.cartesian3.fromDegrees(-97.74, 30.27, 60_000),
|
||||||
destination: ell.cartographicToCartesian({
|
|
||||||
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 60_000,
|
|
||||||
}),
|
|
||||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
});
|
});
|
||||||
for (const [id, entry] of gev.dataManager.layers) {
|
for (const [id, entry] of gev.dataManager.layers) {
|
||||||
@@ -128,9 +140,9 @@ try {
|
|||||||
|
|
||||||
/** Count scene postRender fires and rAF ticks over windowMs. */
|
/** Count scene postRender fires and rAF ticks over windowMs. */
|
||||||
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
|
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;
|
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 t0 = performance.now();
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
rafs += 1;
|
rafs += 1;
|
||||||
@@ -255,10 +267,10 @@ try {
|
|||||||
const detectMove = await Promise.all([
|
const detectMove = await Promise.all([
|
||||||
countFrames(2_500),
|
countFrames(2_500),
|
||||||
page.evaluate(() => new Promise((resolve) => {
|
page.evaluate(() => new Promise((resolve) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const { camera } = window.__godsEyeView.engine;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
v.camera.moveForward(50);
|
camera.zoomIn(50);
|
||||||
steps += 1;
|
steps += 1;
|
||||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||||
}, 60);
|
}, 60);
|
||||||
@@ -281,13 +293,13 @@ try {
|
|||||||
// identity is one painted frame. Sampling it per scene frame is enough to
|
// identity is one painted frame. Sampling it per scene frame is enough to
|
||||||
// tell "painting" from "silent" without reaching into module internals.
|
// tell "painting" from "silent" without reaching into module internals.
|
||||||
let last = gev.styleManager.getDetectionDiagnostics?.();
|
let last = gev.styleManager.getDetectionDiagnostics?.();
|
||||||
const remove = gev.viewer.scene.postRender.addEventListener(() => {
|
const remove = gev.engine.frames.postRender.addEventListener(() => {
|
||||||
const now = gev.styleManager.getDetectionDiagnostics?.();
|
const now = gev.styleManager.getDetectionDiagnostics?.();
|
||||||
if (now && now !== last) { paints += 1; last = now; }
|
if (now && now !== last) { paints += 1; last = now; }
|
||||||
});
|
});
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
gev.viewer.camera.moveForward(50);
|
gev.engine.camera.zoomIn(50);
|
||||||
steps += 1;
|
steps += 1;
|
||||||
if (steps >= 20) {
|
if (steps >= 20) {
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
@@ -325,9 +337,15 @@ try {
|
|||||||
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
|
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
|
||||||
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
|
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
|
||||||
const dAnim = await diag();
|
const dAnim = await diag();
|
||||||
|
// 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);
|
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
|
||||||
const animFrames = await countFrames(2_000);
|
const animFrames = await countFrames(2_000);
|
||||||
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
|
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
|
// Returning to normal keeps detection ON by design (it is the default, and
|
||||||
// detection persists across style switches). Since 2026-08-22 detection holds
|
// detection persists across style switches). Since 2026-08-22 detection holds
|
||||||
// nothing, so this asserts the strictly harder thing: the scene returns to
|
// nothing, so this asserts the strictly harder thing: the scene returns to
|
||||||
@@ -349,26 +367,26 @@ try {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
|
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
|
||||||
await page.evaluate(async () => {
|
if (await layerAvailable('satellites')) {
|
||||||
await window.__godsEyeView.dataManager.setEnabled('satellites', true, { origin: 'user' });
|
await page.evaluate(() => window.__godsEyeView.layers.enable('satellites'));
|
||||||
});
|
|
||||||
const dSat = await diag();
|
const dSat = await diag();
|
||||||
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
|
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(() => window.__godsEyeView.layers.disable('satellites'));
|
||||||
await window.__godsEyeView.dataManager.setEnabled('satellites', false, { origin: 'user' });
|
|
||||||
});
|
|
||||||
await new Promise((r) => setTimeout(r, 2_000));
|
await new Promise((r) => setTimeout(r, 2_000));
|
||||||
const dSatOff = await diag();
|
const dSatOff = await diag();
|
||||||
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
|
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) ───────────────────
|
// ── 3. camera movement renders (Cesium-native path) ───────────────────
|
||||||
const duringMove = await Promise.all([
|
const duringMove = await Promise.all([
|
||||||
countFrames(2_500),
|
countFrames(2_500),
|
||||||
page.evaluate(() => new Promise((resolve) => {
|
page.evaluate(() => new Promise((resolve) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const { camera } = window.__godsEyeView.engine;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
v.camera.moveForward(50);
|
camera.zoomIn(50);
|
||||||
steps += 1;
|
steps += 1;
|
||||||
if (steps >= 20) { clearInterval(id); resolve(true); }
|
if (steps >= 20) { clearInterval(id); resolve(true); }
|
||||||
}, 60);
|
}, 60);
|
||||||
@@ -378,8 +396,9 @@ try {
|
|||||||
await new Promise((r) => setTimeout(r, 3_000)); // settle
|
await new Promise((r) => setTimeout(r, 3_000)); // settle
|
||||||
|
|
||||||
// ── 4. flights enabled → continuous ───────────────────────────────────
|
// ── 4. flights enabled → continuous ───────────────────────────────────
|
||||||
|
if (await layerAvailable('flights')) {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await window.__godsEyeView.dataManager.setEnabled('flights', true, { origin: 'user' });
|
await window.__godsEyeView.layers.enable('flights');
|
||||||
});
|
});
|
||||||
await new Promise((r) => setTimeout(r, 5_000));
|
await new Promise((r) => setTimeout(r, 5_000));
|
||||||
const active = await countFrames(5_000);
|
const active = await countFrames(5_000);
|
||||||
@@ -390,7 +409,7 @@ try {
|
|||||||
|
|
||||||
// ── 5. flights disabled → idle again ──────────────────────────────────
|
// ── 5. flights disabled → idle again ──────────────────────────────────
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
await window.__godsEyeView.dataManager.setEnabled('flights', false, { origin: 'user' });
|
await window.__godsEyeView.layers.disable('flights');
|
||||||
});
|
});
|
||||||
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
|
// 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
|
// its occluders against all have to drain first — and this teardown, like the
|
||||||
@@ -403,11 +422,14 @@ try {
|
|||||||
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
|
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('governor returns to idle after disable', d5?.mode === 'idle', d5);
|
||||||
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
|
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 {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = results.filter((r) => r.pass).length;
|
const passed = results.filter((r) => r.pass).length;
|
||||||
console.log(`\nqa-perf: ${passed}/${results.length} passed`);
|
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);
|
process.exit(passed === results.length ? 0 : 1);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]), {});
|
||||||
|
});
|
||||||
@@ -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<boolean>
|
||||||
|
// 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { createStandaloneData } from './data.js';
|
|||||||
import { createStandaloneTools } from './tools.js';
|
import { createStandaloneTools } from './tools.js';
|
||||||
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||||
import { resolveEngineKind } from '../app/engineChoice.js';
|
import { resolveEngineKind } from '../app/engineChoice.js';
|
||||||
|
import { createQaProbe } from '../app/qaProbe.js';
|
||||||
|
|
||||||
// The existing controls and layer catalog contain page-scoped state.
|
// The existing controls and layer catalog contain page-scoped state.
|
||||||
let constructed = false;
|
let constructed = false;
|
||||||
@@ -65,9 +66,18 @@ export function createStandaloneApplication({
|
|||||||
// The catalog of this engine: on Navara only the layers with a Navara
|
// The catalog of this engine: on Navara only the layers with a Navara
|
||||||
// renderer (src/app/layers/renderers.js; none yet), the others' state
|
// renderer (src/app/layers/renderers.js; none yet), the others' state
|
||||||
// kept for links.
|
// 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({
|
catalog = createStandaloneCatalog({
|
||||||
engineKind,
|
engineKind,
|
||||||
sources,
|
sources: { ...sources, ...qaSources },
|
||||||
streetLevelProviders,
|
streetLevelProviders,
|
||||||
nepalBoundaryResolver: (signal) =>
|
nepalBoundaryResolver: (signal) =>
|
||||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||||
@@ -91,7 +101,28 @@ export function createStandaloneApplication({
|
|||||||
}),
|
}),
|
||||||
createData: (context) =>
|
createData: (context) =>
|
||||||
createStandaloneData({ ...context, allowQaRegistration, catalog }),
|
createStandaloneData({ ...context, allowQaRegistration, catalog }),
|
||||||
createTools: (context) =>
|
createTools: async (context) => {
|
||||||
createStandaloneTools({ ...context, loadingScreen, placeSearch, voice }),
|
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;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user