mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Measure engine coupling, QA harness use and contract coverage
Counters that may only fall, each with a committed baseline and a test that fails on growth: - report-engine-coupling.mjs gains layer modules that take the viewer in attachRenderer (33), layer folders with their own "view being restored" flag (9), engine.surface calls outside src/globe (41) and layer ids named in the engine switch (3). - report-qa-harness.mjs counts QA scripts that launch Chrome themselves (98), wait with fixed sleeps (117), take the hidden loading screen as readiness (9), read Cesium-only objects (75) or define their own waitFor helpers (19). - withCallLog records every contract member the contract suite calls on the fake engine; contractCoverage.test.mjs fails when a member outside contract-uncalled-baseline.json goes uncalled, and when a listed one is called now, so the list of 12 only shrinks.
This commit is contained in:
1 parent
ba04ceaa32
commit
9c2a2d6951
11 files changed
+842
-3
No files matched your search
@@ -604,6 +604,18 @@ checks. Test ownership and repeated destruction as well as visible output.
|
||||
The browser harness is `scripts/qa-contract-suite.mjs`; use
|
||||
`--only=ground:,imagery:` for ground and imagery pixel checks.
|
||||
|
||||
Three reports keep migration debt from growing: `scripts/report-engine-coupling.mjs`
|
||||
counts application coupling, `scripts/report-qa-harness.mjs` counts browser QA
|
||||
duplication, and `src/globe/testing/contractCoverage.test.mjs` reports contract
|
||||
members the fake-engine suite leaves uncalled. After removing coupling or moving
|
||||
QA work to `scripts/qa-strict-harness.mjs` helpers, run the matching report with
|
||||
`--write-baseline` and keep only decreases in its baseline. After adding behavioral
|
||||
contract checks, remove the newly exercised names from
|
||||
`src/globe/testing/contract-uncalled-baseline.json`; keep it sorted, and remove
|
||||
members that no longer exist too. Run the matching tests with
|
||||
`node --import=./build/registerVue.js --test` followed by their paths; the numeric
|
||||
baselines reject growth, and the contract baseline also rejects stale entries.
|
||||
|
||||
The application currently registers two kinds: adding another still requires
|
||||
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
|
||||
QA harnesses. `assertGlobeEngine` currently validates that registration. The
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"metrics": {
|
||||
"attachRendererWithViewer": 33,
|
||||
"restoreFlagCopies": 9,
|
||||
"surfaceCallsOutsideGlobe": 41,
|
||||
"engineSwitchLayerIds": 3,
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 3,
|
||||
@@ -11,6 +15,5 @@
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 2,
|
||||
"engineNameLines": 21
|
||||
},
|
||||
"measuredAt": "Orbital cockpit and bridge mesh sampling"
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,85 @@ test('a metric that grows is reported, one that falls is not', () => {
|
||||
['viewerLines'],
|
||||
);
|
||||
});
|
||||
|
||||
test('renderer attachment counts definitions once per module, not calls', () => {
|
||||
const { measure } = ENGINE_COUPLING_METRICS.find(
|
||||
({ id }) => id === 'attachRendererWithViewer',
|
||||
);
|
||||
assert.equal(
|
||||
measure([
|
||||
{
|
||||
text: 'attachRenderer(viewer, options = {}) {}\nattachRenderer(scene) {}',
|
||||
},
|
||||
{ text: 'function attachRenderer(\n _viewer, options\n) {}' },
|
||||
{ text: 'const attachRenderer = (nextViewer) => {};' },
|
||||
{
|
||||
text: 'attachRenderer(_scene); layer.attachRenderer(viewer, options);',
|
||||
},
|
||||
{ text: 'attachRenderer(engine) {}' },
|
||||
{ text: 'attachRenderer(viewerState) {}' },
|
||||
]),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('restore flags count layer directories and surface calls exclude globe', () => {
|
||||
const measure = (id, files) =>
|
||||
ENGINE_COUPLING_METRICS.find((metric) => metric.id === id).measure(files);
|
||||
assert.equal(
|
||||
measure('restoreFlagCopies', [
|
||||
{
|
||||
file: 'src/layers/traffic/lifecycle.js',
|
||||
text: 'state._restoringViewport = true;',
|
||||
},
|
||||
{
|
||||
file: 'src/layers/traffic/viewport.js',
|
||||
text: 'state._restoringViewport',
|
||||
},
|
||||
{ file: 'src/layers/alpr/index.js', text: 'let restoringView = false;' },
|
||||
{
|
||||
file: 'src/layers/streetLevel/index.js',
|
||||
text: 'let waitingForView = false;',
|
||||
},
|
||||
{ file: 'src/app/view.js', text: 'let restoringView = false;' },
|
||||
]),
|
||||
3,
|
||||
);
|
||||
const text = [
|
||||
'terrainHeight',
|
||||
'sampleHeight',
|
||||
'sampleHeightDetailed',
|
||||
'sampleTerrainMostDetailed',
|
||||
'clampToHeight',
|
||||
'raycast',
|
||||
]
|
||||
.map((name) => `engine.surface.${name}(point);`)
|
||||
.join('\n');
|
||||
assert.equal(
|
||||
measure('surfaceCallsOutsideGlobe', [
|
||||
{ file: 'src/globe/navara/surface.js', text },
|
||||
{ file: 'src/layers/traffic/viewport.js', text },
|
||||
{
|
||||
file: 'src/app/view.js',
|
||||
text: 'engine.surface?.raycast?.(ray); surface.tilesLoaded();',
|
||||
},
|
||||
]),
|
||||
7,
|
||||
);
|
||||
});
|
||||
|
||||
test('engine switch layer ids count lines and follow the catalog', () => {
|
||||
const { measure } = ENGINE_COUPLING_METRICS.find(
|
||||
({ id }) => id === 'engineSwitchLayerIds',
|
||||
);
|
||||
assert.equal(
|
||||
measure([
|
||||
{
|
||||
file: 'src/standalone/engineSwitch.js',
|
||||
text: "get('directions'); get('satellites');\nget('traffic');\nget('not-a-layer');",
|
||||
},
|
||||
{ file: 'src/app/view.js', text: "get('directions');" },
|
||||
]),
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"metrics": {
|
||||
"ownLaunch": 98,
|
||||
"fixedSleeps": 117,
|
||||
"loadingScreenReadiness": 9,
|
||||
"cesiumOnlyReads": 75,
|
||||
"ownWaitHelpers": 19
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
QA_HARNESS_METRICS,
|
||||
harnessGrowth,
|
||||
measureQaHarness,
|
||||
readBaseline,
|
||||
readQaSources,
|
||||
} from './report-qa-harness.mjs';
|
||||
|
||||
test('QA harness duplication does not grow past the baseline', () => {
|
||||
assert.deepEqual(
|
||||
harnessGrowth(measureQaHarness(), readBaseline().metrics),
|
||||
[],
|
||||
'QA harness duplication grew; use scripts/qa-strict-harness.mjs helpers for browser launch, engine readiness and camera waits instead.',
|
||||
);
|
||||
});
|
||||
|
||||
test('the baseline names every metric', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(readBaseline().metrics).sort(),
|
||||
QA_HARNESS_METRICS.map(({ id }) => id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a metric that grows is reported, one that falls is not', () => {
|
||||
const baseline = Object.fromEntries(
|
||||
QA_HARNESS_METRICS.map(({ id }) => [id, 10]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
harnessGrowth({ ...baseline, ownLaunch: 11, fixedSleeps: 3 }, baseline).map(
|
||||
({ id }) => id,
|
||||
),
|
||||
['ownLaunch'],
|
||||
);
|
||||
assert.equal(harnessGrowth(baseline, {}).length, QA_HARNESS_METRICS.length);
|
||||
});
|
||||
|
||||
test('QA sources include the harness and exclude tests and other reports', () => {
|
||||
const names = readQaSources().map(({ file }) => file);
|
||||
assert.ok(names.includes('scripts/qa-strict-harness.mjs'));
|
||||
assert.ok(!names.some((name) => name.endsWith('.test.mjs')));
|
||||
assert.ok(!names.includes('scripts/report-qa-harness.mjs'));
|
||||
});
|
||||
|
||||
test('metrics count scripts once and distinguish helpers from their callers', () => {
|
||||
const measure = (id, ...sources) =>
|
||||
QA_HARNESS_METRICS.find((metric) => metric.id === id).measure(
|
||||
sources.map((text) => ({ text })),
|
||||
);
|
||||
assert.equal(
|
||||
measure(
|
||||
'ownLaunch',
|
||||
'puppeteer.launch({}); puppeteer.launch({});',
|
||||
'launchBrowser();',
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
measure(
|
||||
'fixedSleeps',
|
||||
'const sleep = (ms) => new Promise((r) => setTimeout(r, ms));',
|
||||
'await new Promise((resolve) => { setTimeout(resolve, 1000); });',
|
||||
'await new Promise((resolve) => setTimeout(resolve));',
|
||||
'new Promise((resolve) => requestAnimationFrame(resolve));',
|
||||
'setTimeout(() => reject(new Error()), 1000);',
|
||||
),
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
measure(
|
||||
'loadingScreenReadiness',
|
||||
"page.waitForSelector('#loading-screen');",
|
||||
"page.waitForSelector('#loading-screen'); await waitForEngine(page);",
|
||||
"document.querySelector('#loading-screen');",
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
measure(
|
||||
'cesiumOnlyReads',
|
||||
'window.__godsEyeView?.viewer; window.__godsEyeView.tileset;',
|
||||
'const { scene } = window.__godsEyeView;',
|
||||
'const app = window.__godsEyeView; app.viewer;',
|
||||
'window.__godsEyeView.sceneDirector;',
|
||||
'window.__godsEyeView.engine;',
|
||||
),
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
measure(
|
||||
'ownWaitHelpers',
|
||||
'async function waitForReady() {}',
|
||||
'const waitForReady = async (page) => page.ready();',
|
||||
'await waitForEngine(page);',
|
||||
"import { waitForEngine } from './qa-strict-harness.mjs';",
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
export const BASELINE_PATH = path.join(
|
||||
@@ -37,12 +38,72 @@ const countLines = (text, pattern) =>
|
||||
lines(text).filter((line) => pattern.test(line)).length;
|
||||
const countMatches = (text, pattern) =>
|
||||
(text.match(new RegExp(pattern.source, 'g')) ?? []).length;
|
||||
const layerIds = new Set(APPLICATION_LAYER_DIRECTORY.map(({ id }) => id));
|
||||
|
||||
/**
|
||||
* 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: 'attachRendererWithViewer',
|
||||
label: 'Modules defining attachRenderer with a viewer or scene parameter',
|
||||
measure: (files) =>
|
||||
files.filter(({ text }) =>
|
||||
/\battachRenderer\s*(?:(?:=|:)\s*(?:async\s+)?(?:function\s*)?)?\(\s*(?:viewer|_viewer|nextViewer|_scene|scene)\s*(?:,[^)]*|=[^)]*)?\)\s*(?:\{|=>)/.test(
|
||||
text,
|
||||
),
|
||||
).length,
|
||||
},
|
||||
{
|
||||
id: 'restoreFlagCopies',
|
||||
label: 'Layer directories keeping their own view-restore flag',
|
||||
measure: (files) =>
|
||||
new Set(
|
||||
files
|
||||
.filter(
|
||||
({ file, text }) =>
|
||||
file.startsWith('src/layers/') &&
|
||||
/\b(?:_restoringViewport|restoringView|waitingForView)\b/.test(
|
||||
text,
|
||||
),
|
||||
)
|
||||
.map(({ file }) => file.split('/')[2]),
|
||||
).size,
|
||||
},
|
||||
{
|
||||
id: 'surfaceCallsOutsideGlobe',
|
||||
label: 'Surface sampling calls outside src/globe',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => !inGlobe(file))
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum +
|
||||
countMatches(
|
||||
text,
|
||||
/\bsurface\??\.\s*(?:terrainHeight|sampleHeight|sampleHeightDetailed|sampleTerrainMostDetailed|clampToHeight|raycast)\s*(?:\?\.)?\(/,
|
||||
),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'engineSwitchLayerIds',
|
||||
label: 'Lines naming catalog layer ids in src/standalone/engineSwitch.js',
|
||||
measure: (files) =>
|
||||
files
|
||||
.filter(({ file }) => file === 'src/standalone/engineSwitch.js')
|
||||
.reduce(
|
||||
(sum, { text }) =>
|
||||
sum +
|
||||
lines(text).filter((line) =>
|
||||
[...line.matchAll(/(['"])([^'"\n]+)\1/g)].some((match) =>
|
||||
layerIds.has(match[2]),
|
||||
),
|
||||
).length,
|
||||
0,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cesiumLayerRendererLines',
|
||||
label: 'Lines in src/layers/**/render/cesium*',
|
||||
@@ -170,7 +231,11 @@ export function readProductionSources(root = ROOT) {
|
||||
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))
|
||||
else if (
|
||||
entry.isFile() &&
|
||||
/\.(?:js|vue)$/.test(entry.name) &&
|
||||
!/\.test\./.test(entry.name)
|
||||
)
|
||||
files.push({
|
||||
file: path.relative(root, absolute).split(path.sep).join('/'),
|
||||
text: fs.readFileSync(absolute, 'utf8'),
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser QA still doing work owned by scripts/qa-strict-harness.mjs.
|
||||
* Each metric counts scripts, so repeated uses in one script count once.
|
||||
*
|
||||
* node scripts/report-qa-harness.mjs
|
||||
* node scripts/report-qa-harness.mjs --json
|
||||
* node scripts/report-qa-harness.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',
|
||||
'qa-harness-baseline.json',
|
||||
);
|
||||
|
||||
const readsCesiumState = (text) => {
|
||||
const names = [
|
||||
'__godsEyeView',
|
||||
...[
|
||||
...text.matchAll(
|
||||
/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:window\.)?__godsEyeView\s*[;,]/g,
|
||||
),
|
||||
].map((match) => match[1]),
|
||||
];
|
||||
return names.some((name) =>
|
||||
new RegExp(
|
||||
`\\b${name}\\s*\\??\\.\\s*(?:viewer|tileset|scene)\\b|\\{[^{}]*\\b(?:viewer|tileset|scene)\\b[^{}]*\\}\\s*=\\s*(?:window\\.)?${name}\\b`,
|
||||
).test(text),
|
||||
);
|
||||
};
|
||||
|
||||
export const QA_HARNESS_METRICS = Object.freeze([
|
||||
{
|
||||
id: 'ownLaunch',
|
||||
label: 'Scripts launching puppeteer themselves',
|
||||
measure: (files) =>
|
||||
files.filter(({ text }) => /\bpuppeteer\.launch\s*\(/.test(text)).length,
|
||||
},
|
||||
{
|
||||
id: 'fixedSleeps',
|
||||
label: 'Scripts with setTimeout-based pauses',
|
||||
measure: (files) =>
|
||||
files.filter(({ text }) =>
|
||||
/new\s+Promise\s*\(\s*\(?\s*(\w+)\s*\)?\s*=>\s*(?:\{\s*(?:return\s+)?)?setTimeout\s*\(\s*\1\s*[,)]/.test(
|
||||
text,
|
||||
),
|
||||
).length,
|
||||
},
|
||||
{
|
||||
id: 'loadingScreenReadiness',
|
||||
label: 'Scripts waiting for #loading-screen without waitForEngine',
|
||||
measure: (files) =>
|
||||
files.filter(
|
||||
({ text }) =>
|
||||
/\bwaitFor(?:Selector|Function)\s*\([^;]*#loading-screen\b/.test(
|
||||
text,
|
||||
) && !/\bwaitForEngine\s*\(/.test(text),
|
||||
).length,
|
||||
},
|
||||
{
|
||||
id: 'cesiumOnlyReads',
|
||||
label: 'Scripts reading __godsEyeView viewer, tileset or scene',
|
||||
measure: (files) =>
|
||||
files.filter(({ text }) => readsCesiumState(text)).length,
|
||||
},
|
||||
{
|
||||
id: 'ownWaitHelpers',
|
||||
label: 'Scripts defining their own waitFor functions',
|
||||
measure: (files) =>
|
||||
files.filter(({ text }) =>
|
||||
/\bfunction\s+waitFor\w*\s*\(|\b(?:const|let|var)\s+waitFor\w*\s*=\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|\w+\s*=>)/.test(
|
||||
text,
|
||||
),
|
||||
).length,
|
||||
},
|
||||
]);
|
||||
|
||||
/** QA scripts, including the shared harness itself, but excluding tests. */
|
||||
export function readQaSources(root = ROOT) {
|
||||
return fs
|
||||
.readdirSync(path.join(root, 'scripts'))
|
||||
.filter((name) => /^qa-.*\.mjs$/.test(name) && !name.endsWith('.test.mjs'))
|
||||
.sort()
|
||||
.map((name) => ({
|
||||
file: `scripts/${name}`,
|
||||
text: fs.readFileSync(path.join(root, 'scripts', name), 'utf8'),
|
||||
}));
|
||||
}
|
||||
|
||||
export function measureQaHarness(root = ROOT) {
|
||||
const files = readQaSources(root);
|
||||
return Object.fromEntries(
|
||||
QA_HARNESS_METRICS.map(({ id, measure }) => [id, measure(files)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function harnessGrowth(measured, baseline) {
|
||||
return QA_HARNESS_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 = measureQaHarness();
|
||||
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 QA_HARNESS_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));
|
||||
@@ -1075,6 +1075,15 @@ const SHAPE = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Required capability members checked by assertGlobeEngine. */
|
||||
export function engineContractMembers() {
|
||||
return Object.entries(SHAPE)
|
||||
.flatMap(([namespace, members]) =>
|
||||
Object.keys(members).map((member) => `${namespace}.${member}`),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const isNameList = (value) =>
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { engineContractMembers, isEngineEvent } from '../contract.js';
|
||||
|
||||
// Returned handles keep the names of their contract typedefs.
|
||||
const HANDLE_TYPES = {
|
||||
MoverSet: {
|
||||
creators: ['movers.createSet'],
|
||||
members: [
|
||||
'set',
|
||||
'setCount',
|
||||
'count',
|
||||
'commit',
|
||||
'ready',
|
||||
'failed',
|
||||
'destroy',
|
||||
],
|
||||
},
|
||||
Line: {
|
||||
creators: ['lines.createLine'],
|
||||
members: ['setPositions', 'setColor', 'setModelMatrix', 'show', 'destroy'],
|
||||
},
|
||||
LineSegments: {
|
||||
creators: ['lines.createSegments'],
|
||||
members: ['replace', 'commit', 'show', 'destroy'],
|
||||
},
|
||||
GroundSet: {
|
||||
creators: ['ground.createSet'],
|
||||
members: [
|
||||
'id',
|
||||
'setReveal',
|
||||
'replace',
|
||||
'commit',
|
||||
'show',
|
||||
'shown',
|
||||
'count',
|
||||
'ready',
|
||||
'destroy',
|
||||
],
|
||||
},
|
||||
GroundPulseField: {
|
||||
creators: ['ground.createPulseField'],
|
||||
members: ['id', 'draw', 'move', 'destroy', 'isDestroyed'],
|
||||
},
|
||||
ImageryLayer: {
|
||||
creators: ['imagery.createLayer'],
|
||||
members: [
|
||||
'id',
|
||||
'setSource',
|
||||
'source',
|
||||
'staging',
|
||||
'setDetail',
|
||||
'detail',
|
||||
'raise',
|
||||
'setAlpha',
|
||||
'alpha',
|
||||
'show',
|
||||
'shown',
|
||||
'setSplit',
|
||||
'split',
|
||||
'ready',
|
||||
'destroy',
|
||||
],
|
||||
},
|
||||
GlobeMesh: {
|
||||
creators: ['meshes.createMesh'],
|
||||
members: ['id', 'update', 'show', 'destroy'],
|
||||
},
|
||||
FollowHandle: {
|
||||
creators: ['camera.follow', 'camera.following'],
|
||||
members: ['stop', 'retarget', 'target', 'native', 'label', 'name'],
|
||||
},
|
||||
PointerClaim: {
|
||||
creators: ['input.acquirePointer'],
|
||||
members: ['release'],
|
||||
},
|
||||
EngineEvent: {
|
||||
creators: [],
|
||||
members: ['addEventListener', 'removeEventListener'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Members tracked by the suite, including handles returned by the engine. */
|
||||
export function contractCallMembers() {
|
||||
return [
|
||||
...engineContractMembers(),
|
||||
...Object.entries(HANDLE_TYPES).flatMap(([type, { members }]) =>
|
||||
members.map((member) => `${type}.${member}`),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
/** Log contract calls and value reads without changing the engine's receiver. */
|
||||
export function withCallLog(engine) {
|
||||
const called = new Set();
|
||||
const members = new Set(contractCallMembers());
|
||||
const namespaces = new Set(
|
||||
engineContractMembers().map((name) => name.split('.')[0]),
|
||||
);
|
||||
const returnedTypes = new Map(
|
||||
Object.entries(HANDLE_TYPES).flatMap(([type, { creators }]) =>
|
||||
creators.map((name) => [name, type]),
|
||||
),
|
||||
);
|
||||
const proxies = new WeakMap();
|
||||
const originals = new WeakMap();
|
||||
const unwrap = (value) => originals.get(value) ?? value;
|
||||
const unwrapExclusions = (exclude) =>
|
||||
Array.isArray(exclude) && exclude.some((value) => originals.has(value))
|
||||
? exclude.map(unwrap)
|
||||
: exclude;
|
||||
|
||||
const wrap = (target, prefix, followEvent = false) => {
|
||||
if (target == null || typeof target !== 'object') return target;
|
||||
let cached = proxies.get(target);
|
||||
if (!cached) proxies.set(target, (cached = new Map()));
|
||||
const key = followEvent ? 'camera.followChanged' : prefix;
|
||||
if (cached.has(key)) return cached.get(key);
|
||||
const methods = new Map();
|
||||
const listeners = new WeakMap();
|
||||
const proxy = new Proxy(target, {
|
||||
get(target, property) {
|
||||
const value = Reflect.get(target, property, target);
|
||||
if (typeof property !== 'string') return value;
|
||||
if (!prefix && namespaces.has(property)) return wrap(value, property);
|
||||
const name = `${prefix}.${property}`;
|
||||
if (typeof value !== 'function') {
|
||||
if (members.has(name)) called.add(name);
|
||||
return isEngineEvent(value)
|
||||
? wrap(value, 'EngineEvent', name === 'camera.followChanged')
|
||||
: value;
|
||||
}
|
||||
if (methods.get(property)?.original === value)
|
||||
return methods.get(property).wrapped;
|
||||
const wrapped = function (...args) {
|
||||
if (members.has(name)) called.add(name);
|
||||
if (
|
||||
followEvent &&
|
||||
(property === 'addEventListener' ||
|
||||
property === 'removeEventListener') &&
|
||||
typeof args[0] === 'function'
|
||||
) {
|
||||
const listener = args[0];
|
||||
if (!listeners.has(listener))
|
||||
listeners.set(listener, (handle) =>
|
||||
listener(wrap(handle, 'FollowHandle')),
|
||||
);
|
||||
args[0] = listeners.get(listener);
|
||||
}
|
||||
if (name === 'input.on' && args[2]?.pointer) {
|
||||
args[2] = { ...args[2], pointer: unwrap(args[2].pointer) };
|
||||
}
|
||||
// Surface adapters identify excluded resources by their native handles.
|
||||
if (
|
||||
name === 'surface.sampleHeight' ||
|
||||
name === 'surface.clampToHeight'
|
||||
) {
|
||||
if (args.length > 1) args[1] = unwrapExclusions(args[1]);
|
||||
} else if (
|
||||
name === 'surface.sampleHeightDetailed' ||
|
||||
name === 'surface.raycast'
|
||||
) {
|
||||
const index = name === 'surface.raycast' ? 0 : 1;
|
||||
const options = args[index];
|
||||
const exclude = unwrapExclusions(options?.exclude);
|
||||
if (exclude !== options?.exclude)
|
||||
args[index] = { ...options, exclude };
|
||||
}
|
||||
const result = Reflect.apply(value, target, args);
|
||||
const type = returnedTypes.get(name);
|
||||
return type ? wrap(result, type) : result;
|
||||
};
|
||||
methods.set(property, { original: value, wrapped });
|
||||
return wrapped;
|
||||
},
|
||||
set(target, property, value) {
|
||||
return Reflect.set(target, property, unwrap(value), target);
|
||||
},
|
||||
});
|
||||
cached.set(key, proxy);
|
||||
originals.set(proxy, target);
|
||||
return proxy;
|
||||
};
|
||||
|
||||
return { engine: wrap(engine, ''), called: () => [...called].sort() };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
"FollowHandle.label",
|
||||
"FollowHandle.name",
|
||||
"GlobeMesh.id",
|
||||
"GroundPulseField.id",
|
||||
"Line.setColor",
|
||||
"Line.setModelMatrix",
|
||||
"Line.show",
|
||||
"MoverSet.failed",
|
||||
"maps.terrainRevision",
|
||||
"render.maxTextureSize",
|
||||
"surface.raycast",
|
||||
"surface.sampleTerrainMostDetailed"
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { createEngineEvent, engineContractMembers } from '../contract.js';
|
||||
import { contractCallMembers, withCallLog } from './callLog.js';
|
||||
import { createContractSuite } from './contractSuite.mjs';
|
||||
import { createFakeEngine } from './fakeEngine.js';
|
||||
|
||||
test('the contract suite does not leave new members uncalled', async (t) => {
|
||||
const fake = createFakeEngine();
|
||||
const { engine, called } = withCallLog(fake);
|
||||
const checks = createContractSuite(engine, {
|
||||
waitFrames: async (count) => fake.fake.frames(count),
|
||||
pointer: {
|
||||
click: async (x, y) => fake.fake.input('click', { position: { x, y } }),
|
||||
move: async (x, y) =>
|
||||
fake.fake.input('move', {
|
||||
startPosition: { x: x - 1, y },
|
||||
endPosition: { x, y },
|
||||
}),
|
||||
},
|
||||
mapSources: {
|
||||
imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }],
|
||||
photoreal: { kind: 'photoreal' },
|
||||
},
|
||||
});
|
||||
try {
|
||||
for (const check of checks) await t.test(check.name, () => check.run());
|
||||
const baseline = JSON.parse(
|
||||
fs.readFileSync(
|
||||
new URL('./contract-uncalled-baseline.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const seen = new Set(called());
|
||||
const uncalled = contractCallMembers().filter((name) => !seen.has(name));
|
||||
assert.deepEqual(
|
||||
baseline,
|
||||
[...new Set(baseline)].sort(),
|
||||
'Keep contract-uncalled-baseline.json sorted and remove duplicate names.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
uncalled.filter((name) => !baseline.includes(name)),
|
||||
[],
|
||||
'Contract members lost coverage; add behavioral checks to createContractSuite that exercise them instead of growing contract-uncalled-baseline.json.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
baseline.filter((name) => !uncalled.includes(name)),
|
||||
[],
|
||||
'Contract members are now exercised or no longer exist; remove these names from contract-uncalled-baseline.json to lower the baseline.',
|
||||
);
|
||||
} finally {
|
||||
fake.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('call logging keeps receivers, arguments, results and function identity', async () => {
|
||||
const result = { height: 5 };
|
||||
const promise = Promise.resolve(result);
|
||||
const camera = {
|
||||
flyTo(destination) {
|
||||
assert.equal(this, camera);
|
||||
return destination;
|
||||
},
|
||||
};
|
||||
const maps = {
|
||||
setTerrain() {
|
||||
assert.equal(this, maps);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
const { engine, called } = withCallLog({ camera, maps });
|
||||
const flyTo = engine.camera.flyTo;
|
||||
assert.equal(flyTo, engine.camera.flyTo);
|
||||
assert.deepEqual(called(), [], 'shape inspection must not count as a call');
|
||||
assert.equal(flyTo(result), result);
|
||||
assert.equal(engine.maps.setTerrain(), promise);
|
||||
assert.equal(await promise, result);
|
||||
assert.deepEqual(called(), ['camera.flyTo', 'maps.setTerrain']);
|
||||
called().push('not a member');
|
||||
assert.deepEqual(called(), ['camera.flyTo', 'maps.setTerrain']);
|
||||
});
|
||||
|
||||
test('returned handles log under their contract type and retain receivers', () => {
|
||||
const handle = {
|
||||
set() {
|
||||
assert.equal(this, handle);
|
||||
return 42;
|
||||
},
|
||||
};
|
||||
const { engine, called } = withCallLog({
|
||||
movers: { createSet: () => handle },
|
||||
});
|
||||
const set = engine.movers.createSet();
|
||||
assert.equal(set, engine.movers.createSet());
|
||||
assert.equal(set.set(), 42);
|
||||
assert.deepEqual(called(), ['MoverSet.set', 'movers.createSet']);
|
||||
});
|
||||
|
||||
test('getter reads and setters use the original receiver', () => {
|
||||
let revision = 7;
|
||||
const maps = {
|
||||
get terrainRevision() {
|
||||
assert.equal(this, maps);
|
||||
return revision;
|
||||
},
|
||||
set terrainRevision(value) {
|
||||
assert.equal(this, maps);
|
||||
revision = value;
|
||||
},
|
||||
};
|
||||
const { engine, called } = withCallLog({ maps });
|
||||
assert.deepEqual(called(), []);
|
||||
assert.equal(engine.maps.terrainRevision, 7);
|
||||
engine.maps.terrainRevision = 8;
|
||||
assert.equal(engine.maps.terrainRevision, 8);
|
||||
assert.deepEqual(called(), ['maps.terrainRevision']);
|
||||
});
|
||||
|
||||
test('surface exclusions reach the engine as its original handles', () => {
|
||||
const handle = { set() {} };
|
||||
const point = {};
|
||||
const surface = {
|
||||
sampleHeight(position, exclude) {
|
||||
assert.equal(position, point);
|
||||
assert.equal(exclude[0], handle);
|
||||
return 12;
|
||||
},
|
||||
sampleHeightDetailed(position, { exclude }) {
|
||||
assert.equal(position, point);
|
||||
assert.equal(exclude[0], handle);
|
||||
return { height: 12, source: 'terrain' };
|
||||
},
|
||||
clampToHeight(position, exclude) {
|
||||
assert.equal(exclude[0], handle);
|
||||
return position;
|
||||
},
|
||||
raycast({ exclude }) {
|
||||
assert.equal(exclude[0], handle);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const { engine } = withCallLog({
|
||||
movers: { createSet: () => handle },
|
||||
surface,
|
||||
});
|
||||
const set = engine.movers.createSet();
|
||||
const exclude = [set];
|
||||
assert.equal(engine.surface.sampleHeight(point, exclude), 12);
|
||||
assert.deepEqual(engine.surface.sampleHeightDetailed(point, { exclude }), {
|
||||
height: 12,
|
||||
source: 'terrain',
|
||||
});
|
||||
assert.equal(engine.surface.clampToHeight(point, exclude), point);
|
||||
assert.equal(engine.surface.raycast({ exclude }), undefined);
|
||||
assert.equal(exclude[0], set, 'caller-owned exclusions must stay unchanged');
|
||||
});
|
||||
|
||||
test('unknown members remain undefined or throw the original error', () => {
|
||||
const error = new Error('unavailable');
|
||||
const camera = {
|
||||
get unknown() {
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
const { engine, called } = withCallLog({ camera });
|
||||
assert.equal(engine.missing, undefined);
|
||||
assert.equal(engine.camera.missing, camera.missing);
|
||||
assert.throws(() => engine.camera.missing(), TypeError);
|
||||
assert.throws(
|
||||
() => engine.camera.unknown,
|
||||
(caught) => caught === error,
|
||||
);
|
||||
assert.deepEqual(called(), []);
|
||||
});
|
||||
|
||||
test('follow events retain handle identity, duplicate listeners and removers', () => {
|
||||
const followChanged = createEngineEvent();
|
||||
const handle = { stop() {}, retarget() {}, target() {} };
|
||||
const camera = {
|
||||
followChanged,
|
||||
follow() {
|
||||
followChanged.raiseEvent(handle);
|
||||
return handle;
|
||||
},
|
||||
following: () => handle,
|
||||
};
|
||||
const { engine } = withCallLog({ camera });
|
||||
const seen = [];
|
||||
const listener = (value) => seen.push(value);
|
||||
const remove = engine.camera.followChanged.addEventListener(listener);
|
||||
engine.camera.followChanged.addEventListener(listener);
|
||||
const wrapped = engine.camera.follow();
|
||||
assert.equal(engine.camera.following(), wrapped);
|
||||
assert.deepEqual(seen, [wrapped]);
|
||||
assert.equal(engine.camera.followChanged.removeEventListener(listener), true);
|
||||
remove();
|
||||
engine.camera.follow();
|
||||
assert.equal(seen.length, 1);
|
||||
});
|
||||
|
||||
test('contract inventory is sorted, unique and includes required capabilities and handles', () => {
|
||||
const members = contractCallMembers();
|
||||
assert.deepEqual(members, [...new Set(members)].sort());
|
||||
for (const name of [
|
||||
'camera.flyTo',
|
||||
'maps.terrainRevision',
|
||||
'FollowHandle.retarget',
|
||||
'Line.setPositions',
|
||||
'GroundSet.replace',
|
||||
'ImageryLayer.setAlpha',
|
||||
'GlobeMesh.update',
|
||||
])
|
||||
assert.ok(members.includes(name), name);
|
||||
const names = engineContractMembers();
|
||||
assert.ok(names.every((name) => members.includes(name)));
|
||||
names.length = 0;
|
||||
assert.ok(engineContractMembers().length > 0);
|
||||
});
|
||||
Reference in new issue
Block a user