From 9c2a2d69516893bf2e996a8f6bc4d4c151488aa4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 14:23:29 +0400 Subject: [PATCH] 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. --- docs/GLOBE-PRIMITIVES.md | 12 + scripts/engine-coupling-baseline.json | 7 +- scripts/engineCoupling.test.mjs | 82 +++++++ scripts/qa-harness-baseline.json | 9 + scripts/qaHarness.test.mjs | 100 ++++++++ scripts/report-engine-coupling.mjs | 67 +++++- scripts/report-qa-harness.mjs | 142 ++++++++++++ src/globe/contract.js | 9 + src/globe/testing/callLog.js | 184 +++++++++++++++ .../testing/contract-uncalled-baseline.json | 14 ++ src/globe/testing/contractCoverage.test.mjs | 219 ++++++++++++++++++ 11 files changed, 842 insertions(+), 3 deletions(-) create mode 100644 scripts/qa-harness-baseline.json create mode 100644 scripts/qaHarness.test.mjs create mode 100644 scripts/report-qa-harness.mjs create mode 100644 src/globe/testing/callLog.js create mode 100644 src/globe/testing/contract-uncalled-baseline.json create mode 100644 src/globe/testing/contractCoverage.test.mjs diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 859db1b61..69fe6933b 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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 diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index ff96e5f4d..3d2914c0f 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" + } } diff --git a/scripts/engineCoupling.test.mjs b/scripts/engineCoupling.test.mjs index 7830b26fb..dc2096fae 100644 --- a/scripts/engineCoupling.test.mjs +++ b/scripts/engineCoupling.test.mjs @@ -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, + ); +}); diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json new file mode 100644 index 000000000..10a203a00 --- /dev/null +++ b/scripts/qa-harness-baseline.json @@ -0,0 +1,9 @@ +{ + "metrics": { + "ownLaunch": 98, + "fixedSleeps": 117, + "loadingScreenReadiness": 9, + "cesiumOnlyReads": 75, + "ownWaitHelpers": 19 + } +} diff --git a/scripts/qaHarness.test.mjs b/scripts/qaHarness.test.mjs new file mode 100644 index 000000000..7c8fe0c93 --- /dev/null +++ b/scripts/qaHarness.test.mjs @@ -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, + ); +}); diff --git a/scripts/report-engine-coupling.mjs b/scripts/report-engine-coupling.mjs index 5cda1e7a3..31526a8a1 100644 --- a/scripts/report-engine-coupling.mjs +++ b/scripts/report-engine-coupling.mjs @@ -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'), diff --git a/scripts/report-qa-harness.mjs b/scripts/report-qa-harness.mjs new file mode 100644 index 000000000..b019bc3e0 --- /dev/null +++ b/scripts/report-qa-harness.mjs @@ -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)); diff --git a/src/globe/contract.js b/src/globe/contract.js index a50a6da35..9197c50f2 100644 --- a/src/globe/contract.js +++ b/src/globe/contract.js @@ -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 && diff --git a/src/globe/testing/callLog.js b/src/globe/testing/callLog.js new file mode 100644 index 000000000..906dc8dd5 --- /dev/null +++ b/src/globe/testing/callLog.js @@ -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() }; +} diff --git a/src/globe/testing/contract-uncalled-baseline.json b/src/globe/testing/contract-uncalled-baseline.json new file mode 100644 index 000000000..e69c9e219 --- /dev/null +++ b/src/globe/testing/contract-uncalled-baseline.json @@ -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" +] diff --git a/src/globe/testing/contractCoverage.test.mjs b/src/globe/testing/contractCoverage.test.mjs new file mode 100644 index 000000000..88fe0197a --- /dev/null +++ b/src/globe/testing/contractCoverage.test.mjs @@ -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); +});