mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Measure engine coupling and run the contract suite on the real engines
scripts/report-engine-coupling.mjs counts the engine-specific code left outside src/globe/cesium and src/globe/navara: layer renderer lines per engine, Cesium imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara ground passes, Cesium SDK loads and ScreenSpaceEventHandler users. engineCoupling.test.mjs fails npm test when a count grows past scripts/engine-coupling-baseline.json, so each step towards drawing through the globe contract can only lower them. scripts/qa-contract-suite.mjs runs src/globe/testing/contractSuite.mjs in headless Chrome on the Cesium and Navara of a keyless dev server, which until now only ran on the fake engine. The page imports the suite from the dev server, starts at a fixed view, waits for the camera to rest and lets the pointer through the panels. Cesium passes 57 of 57 checks, Navara 64 of 67; docs/CURRENT-STATE.md names the three that fail.
This commit is contained in:
1 parent
92c8f8d9da
commit
9daa4bd2db
6 files changed
+448
-3
No files matched your search
+13
-2
@@ -178,9 +178,20 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
|
||||
- **The engine contract** is `src/globe/contract.js`. `src/globe/cesium/*` and
|
||||
`src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js` stands
|
||||
in for tests, and `src/globe/testing/contractSuite.mjs` runs the same checks
|
||||
on every engine. `movers` and `lines` are optional capabilities: only
|
||||
in for tests, and `src/globe/testing/contractSuite.mjs` holds the checks
|
||||
every engine must pass. `npm test` runs them on the fake engine;
|
||||
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
|
||||
on the real Cesium and Navara of a keyless dev server. On November 7, 2026
|
||||
Cesium passed 57 of 57 and Navara 64 of 67: a resting move raised no
|
||||
`moveEnd`, a click payload never came, and contact sonar lost an angle-only
|
||||
change inside the app. `movers` and `lines` are optional capabilities: only
|
||||
Navara has them, and the suite skips their checks elsewhere.
|
||||
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
|
||||
engine-specific code outside the engines: renderer lines per engine, Cesium
|
||||
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
|
||||
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
|
||||
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
|
||||
past `scripts/engine-coupling-baseline.json`.
|
||||
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||
camera mode and URL state outlive a switch. A layer that is a session model
|
||||
gets `attachRenderer(engine)` / `detachRenderer()` instead of being rebuilt,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 5239,
|
||||
"navaraLayerRendererLines": 12331,
|
||||
"cesiumImportFiles": 69,
|
||||
"viewerLines": 2424,
|
||||
"navaraKindChecks": 17,
|
||||
"engineSniffCalls": 51,
|
||||
"navaraViewsReads": 27,
|
||||
"groundEffectPasses": 9,
|
||||
"cesiumSdkLoads": 6,
|
||||
"screenSpaceHandlerFiles": 20,
|
||||
"engineNameLines": 175
|
||||
},
|
||||
"measuredAt": "german/navara merged with origin/main 8da8c05e, 2026-11-07"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ENGINE_COUPLING_METRICS,
|
||||
couplingGrowth,
|
||||
measureEngineCoupling,
|
||||
readBaseline,
|
||||
} from './report-engine-coupling.mjs';
|
||||
|
||||
test('engine coupling does not grow past the baseline', () => {
|
||||
const growth = couplingGrowth(
|
||||
measureEngineCoupling(),
|
||||
readBaseline().metrics,
|
||||
);
|
||||
assert.deepEqual(
|
||||
growth,
|
||||
[],
|
||||
'Code outside the globe engines grew more engine-specific. Draw through ' +
|
||||
'the globe contract instead; only a deliberate exception may raise ' +
|
||||
'scripts/engine-coupling-baseline.json (--write-baseline).',
|
||||
);
|
||||
});
|
||||
|
||||
test('the baseline names every metric', () => {
|
||||
const { metrics } = readBaseline();
|
||||
assert.deepEqual(
|
||||
Object.keys(metrics).sort(),
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a metric that grows is reported, one that falls is not', () => {
|
||||
const baseline = Object.fromEntries(
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => [id, 10]),
|
||||
);
|
||||
const measured = { ...baseline, viewerLines: 11, navaraKindChecks: 3 };
|
||||
assert.deepEqual(
|
||||
couplingGrowth(measured, baseline).map(({ id }) => id),
|
||||
['viewerLines'],
|
||||
);
|
||||
});
|
||||
@@ -1616,5 +1616,9 @@
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"src/globe/cesium/doubleClick.js"
|
||||
"scripts/report-engine-coupling.mjs",
|
||||
"scripts/engineCoupling.test.mjs",
|
||||
"scripts/qa-contract-suite.mjs",
|
||||
"src/globe/cesium/doubleClick.js",
|
||||
"scripts/engine-coupling-baseline.json"
|
||||
]
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
|
||||
* headless Chrome against the real engines of a running app, the way
|
||||
* contract.test.mjs runs it against the fake engine.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
|
||||
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
|
||||
*
|
||||
* Serve the app keyless from the dev server: Google and Cesium ion requests
|
||||
* are refused here anyway, so neither engine draws photoreal tiles. The page
|
||||
* imports the suite from the dev server, so it shares the app's modules (an
|
||||
* engine event is recognised by the module that made it). It gets a real
|
||||
* pointer (the pick checks) and no map sources (the maps checks would ask for
|
||||
* photoreal tiles). Exits 1 when any check fails on any engine.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
|
||||
async function runOn(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const { hostname } = new URL(request.url());
|
||||
if (BLOCKED_HOSTS.test(hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
// A view in the link: the app starts there instead of flying to a city.
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
// A cold dev server compiles Navara's modules on the first visit.
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// The suite owns the globe: no camera flight running, and the app's panels
|
||||
// let the pointer through to the canvas.
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
'* { pointer-events: none !important; } canvas { pointer-events: auto !important; }',
|
||||
});
|
||||
// A real left click and pointer move at a canvas position.
|
||||
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
||||
const box = await page.evaluate(() => {
|
||||
const rect =
|
||||
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||
return { left: rect.left, top: rect.top };
|
||||
});
|
||||
const at = [box.left + x, box.top + y];
|
||||
if (type === 'click') await page.mouse.click(...at);
|
||||
else {
|
||||
await page.mouse.move(at[0] - 1, at[1]);
|
||||
await page.mouse.move(...at);
|
||||
}
|
||||
});
|
||||
const results = await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const waitFrames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) return;
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('contract-suite');
|
||||
});
|
||||
const pointer = {
|
||||
click: (x, y) => window.__contractPointer('click', x, y),
|
||||
move: (x, y) => window.__contractPointer('move', x, y),
|
||||
};
|
||||
const { runContractSuite } =
|
||||
await import('/src/globe/testing/contractSuite.mjs');
|
||||
const outcome = await runContractSuite(engine, {
|
||||
waitFrames,
|
||||
pointer,
|
||||
});
|
||||
return outcome.map(({ name, ok, error }) => ({
|
||||
name,
|
||||
ok,
|
||||
error: error ? String(error.message ?? error) : undefined,
|
||||
}));
|
||||
});
|
||||
await page.close();
|
||||
return { engine: kind, results, pageErrors };
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
// A Navara page can hold its main thread for minutes while it starts.
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const report = [];
|
||||
try {
|
||||
for (const kind of engines) {
|
||||
const run = await runOn(browser, kind);
|
||||
report.push(run);
|
||||
const failed = run.results.filter(({ ok }) => !ok);
|
||||
console.log(
|
||||
`${kind}: ${run.results.length - failed.length}/${run.results.length} checks pass`,
|
||||
);
|
||||
for (const { name, error } of failed)
|
||||
console.log(` FAIL ${name}: ${error}`);
|
||||
for (const message of run.pageErrors)
|
||||
console.log(` page error: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exit(
|
||||
report.every(({ results }) => results.every(({ ok }) => ok)) ? 0 : 1,
|
||||
);
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* How tightly the application is still tied to one globe engine: counts of
|
||||
* engine-specific code outside the engines (src/globe/cesium, src/globe/navara).
|
||||
* The renderer-abstraction work drives every number down; none may grow.
|
||||
* engineCoupling.test.mjs (npm test) compares a fresh count with
|
||||
* scripts/engine-coupling-baseline.json.
|
||||
*
|
||||
* Only production sources count: `.js` and `.vue` under src. Tests, fixtures
|
||||
* and test doubles are `.mjs`.
|
||||
*
|
||||
* node scripts/report-engine-coupling.mjs # table
|
||||
* node scripts/report-engine-coupling.mjs --json
|
||||
* node scripts/report-engine-coupling.mjs --write-baseline
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const BASELINE_PATH = path.join(
|
||||
ROOT,
|
||||
'scripts',
|
||||
'engine-coupling-baseline.json',
|
||||
);
|
||||
|
||||
const inEngine = (file) =>
|
||||
file.startsWith('src/globe/cesium/') || file.startsWith('src/globe/navara/');
|
||||
const inGlobe = (file) => file.startsWith('src/globe/');
|
||||
const isLayerRenderer = (engine) => (file) =>
|
||||
file.startsWith('src/layers/') &&
|
||||
new RegExp(`/render/${engine}([^/]*\\.js|/.+\\.js)$`).test(file);
|
||||
const isRenderer = (file) => /\/render\//.test(file);
|
||||
|
||||
const lines = (text) => text.split('\n');
|
||||
const countLines = (text, pattern) =>
|
||||
lines(text).filter((line) => pattern.test(line)).length;
|
||||
const countMatches = (text, pattern) =>
|
||||
(text.match(new RegExp(pattern.source, 'g')) ?? []).length;
|
||||
|
||||
/**
|
||||
* The metrics, in report order. `measure(files)` gets every production
|
||||
* source as `{ file, text }` (file relative to the repository, with `/`).
|
||||
*/
|
||||
export const ENGINE_COUPLING_METRICS = Object.freeze([
|
||||
{
|
||||
id: 'cesiumLayerRendererLines',
|
||||
label: 'Lines in src/layers/**/render/cesium*',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => isLayerRenderer('cesium')(file))
|
||||
.reduce((sum, { text }) => sum + lines(text).length, 0),
|
||||
},
|
||||
{
|
||||
id: 'navaraLayerRendererLines',
|
||||
label: 'Lines in src/layers/**/render/navara*',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => isLayerRenderer('navara')(file))
|
||||
.reduce((sum, { text }) => sum + lines(text).length, 0),
|
||||
},
|
||||
{
|
||||
id: 'cesiumImportFiles',
|
||||
label: "Files importing 'cesium' outside src/globe/cesium and render/",
|
||||
measure: (files) =>
|
||||
files.filter(
|
||||
({ file, text }) =>
|
||||
!file.startsWith('src/globe/cesium/') &&
|
||||
!isRenderer(file) &&
|
||||
/from\s+['"]cesium['"]|import\(\s*['"]cesium['"]\s*\)/.test(text),
|
||||
).length,
|
||||
},
|
||||
{
|
||||
id: 'viewerLines',
|
||||
label: 'Lines naming `viewer` outside src/globe/cesium',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !file.startsWith('src/globe/cesium/'))
|
||||
.reduce((sum, { text }) => sum + countLines(text, /\bviewer\b/), 0),
|
||||
},
|
||||
{
|
||||
id: 'navaraKindChecks',
|
||||
label: "`kind === 'navara'` checks outside src/globe",
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inGlobe(file))
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countMatches(text, /kind\s*[!=]==?\s*['"]navara['"]/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'engineSniffCalls',
|
||||
label: 'isGlobeEngine / engineFromViewer calls outside src/globe',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inGlobe(file))
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countMatches(text, /\b(?:isGlobeEngine|engineFromViewer)\(/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'navaraViewsReads',
|
||||
label: 'Raw Navara reads through navaraViews',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inEngine(file))
|
||||
.reduce(
|
||||
(sum, { text }) => sum + countMatches(text, /\bnavaraViews\.get\(/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'groundEffectPasses',
|
||||
label: 'Navara ground passes (defineGroundEffect calls)',
|
||||
measure: (files) =>
|
||||
files.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countMatches(text, /\bdefineGroundEffect\(\s*\{/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cesiumSdkLoads',
|
||||
label: 'Cesium SDK loads outside the engines and loadCesium.js',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(
|
||||
({ file }) => !inEngine(file) && file !== 'src/app/loadCesium.js',
|
||||
)
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countMatches(text, /\b(?:loadCesiumSdk|whenCesiumSdk)\(/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'screenSpaceHandlerFiles',
|
||||
label: 'Files in src/layers and src/data using ScreenSpaceEventHandler',
|
||||
measure: (files) =>
|
||||
files.filter(
|
||||
({ file, text }) =>
|
||||
(file.startsWith('src/layers/') || file.startsWith('src/data/')) &&
|
||||
/ScreenSpaceEventHandler/.test(text),
|
||||
).length,
|
||||
},
|
||||
{
|
||||
id: 'engineNameLines',
|
||||
label:
|
||||
"Lines with an engine name literal ('navara', 'cesium') outside src/globe",
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inGlobe(file))
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum + countLines(text, /['"](?:navara|cesium)['"]/),
|
||||
0,
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
/** Production sources under `root`/src: `.js` and `.vue`. */
|
||||
export function readProductionSources(root = ROOT) {
|
||||
const files = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(absolute);
|
||||
else if (entry.isFile() && /\.(?:js|vue)$/.test(entry.name))
|
||||
files.push({
|
||||
file: path.relative(root, absolute).split(path.sep).join('/'),
|
||||
text: fs.readFileSync(absolute, 'utf8'),
|
||||
});
|
||||
}
|
||||
};
|
||||
visit(path.join(root, 'src'));
|
||||
return files.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
|
||||
/** Every metric's value for the sources under `root`. */
|
||||
export function measureEngineCoupling(root = ROOT) {
|
||||
const files = readProductionSources(root);
|
||||
return Object.fromEntries(
|
||||
ENGINE_COUPLING_METRICS.map(({ id, measure }) => [id, measure(files)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** The metrics that grew past `baseline`, with both values. */
|
||||
export function couplingGrowth(measured, baseline) {
|
||||
return ENGINE_COUPLING_METRICS.filter(
|
||||
({ id }) => !(measured[id] <= (baseline[id] ?? -Infinity)),
|
||||
).map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
measured: measured[id],
|
||||
baseline: baseline[id],
|
||||
}));
|
||||
}
|
||||
|
||||
export function readBaseline(file = BASELINE_PATH) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const measured = measureEngineCoupling();
|
||||
if (args.includes('--write-baseline')) {
|
||||
const note = args.find((arg) => arg.startsWith('--note='))?.slice(7);
|
||||
fs.writeFileSync(
|
||||
BASELINE_PATH,
|
||||
`${JSON.stringify({ metrics: measured, ...(note ? { measuredAt: note } : {}) }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
if (args.includes('--json')) {
|
||||
console.log(JSON.stringify(measured, null, 2));
|
||||
return;
|
||||
}
|
||||
const baseline = fs.existsSync(BASELINE_PATH) ? readBaseline().metrics : {};
|
||||
for (const { id, label } of ENGINE_COUPLING_METRICS) {
|
||||
const was = baseline[id];
|
||||
const change =
|
||||
was === undefined
|
||||
? ''
|
||||
: ` (baseline ${was}, ${measured[id] - was >= 0 ? '+' : ''}${measured[id] - was})`;
|
||||
console.log(`${String(measured[id]).padStart(7)} ${label}${change}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href)
|
||||
main(process.argv.slice(2));
|
||||
Reference in new issue
Block a user