diff --git a/scripts/qa-harness-baseline.json b/scripts/qa-harness-baseline.json index 42ffba392..fa6a8c058 100644 --- a/scripts/qa-harness-baseline.json +++ b/scripts/qa-harness-baseline.json @@ -4,6 +4,7 @@ "fixedSleeps": 69, "loadingScreenReadiness": 0, "cesiumOnlyReads": 47, - "ownWaitHelpers": 16 + "ownWaitHelpers": 16, + "testEngineLiterals": 2 } } diff --git a/scripts/qaHarness.test.mjs b/scripts/qaHarness.test.mjs index 7c8fe0c93..4a6cfaaaf 100644 --- a/scripts/qaHarness.test.mjs +++ b/scripts/qaHarness.test.mjs @@ -2,17 +2,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { QA_HARNESS_METRICS, + engineLiteralLines, harnessGrowth, measureQaHarness, readBaseline, readQaSources, + readTestSources, } 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.', + 'QA duplication grew; use the shared browser harness and src/globe/testing/fakeEngine.js for test engines.', ); }); @@ -43,6 +45,83 @@ test('QA sources include the harness and exclude tests and other reports', () => assert.ok(!names.includes('scripts/report-qa-harness.mjs')); }); +test('test sources include nested tests and exclude runtime files', () => { + const names = readTestSources().map(({ file }) => file); + assert.ok(names.includes('src/globe/testing/fakeEngine.test.mjs')); + assert.ok(names.includes('scripts/qaHarness.test.mjs')); + assert.ok(names.every((name) => name.endsWith('.test.mjs'))); +}); + +test('engine literals include assignments, inline options and factory returns', () => { + for (const source of [ + 'const engine = {};', + 'let engine; engine = { camera: {} };', + 'scene.engine = { host: {} };', + "scene['engine'] = {};", + 'init({ engine: {} });', + 'const state = { _engine: {} };', + 'const engineB = {}, trafficEngine = {};', + "function createEngine() { return { kind: 'cesium' }; }", + 'const engine = () => ({ camera: {} });', + 'const fakeEngine = () => { return { host: {} }; };', + 'const factory = { createEngine() { return { render: {} }; } };', + 'inspect({ host: { size() {} }, camera: {} });', + "const navara = { kind: 'navara', render: {} };", + ]) + assert.ok(engineLiteralLines(source).length > 0, source); + assert.deepEqual( + engineLiteralLines('const engine = {};\ninit({ engine: {} });'), + [1, 2], + ); +}); + +test('engine literal counting ignores source text, options and harness observations', () => { + for (const source of [ + '// const engine = {};', + "const source = 'const engine = {};';", + 'const source = `init({ engine: {} });`;', + 'const engine = createFakeEngine();', + 'init({ engine: createFakeEngine() });', + 'const options = { engineOptions: {} };', + 'const engineOptions = { canvas: {}, width: 800 };', + "const headers = { host: 'localhost' };", + 'const overlay = { host: { setEntries() {} } };', + 'result[engine] = { camera: {} };', + 'function observedEngine() { return { engine, camera }; }', + 'function observedEngine() { return { snapshots: [] }; }', + 'function observedEngine() { return records.map(() => ({ camera: {} })); }', + ]) + assert.deepEqual(engineLiteralLines(source), [], source); +}); + +test('engine literal metric counts test files once and rejects baseline growth', () => { + const metric = QA_HARNESS_METRICS.find( + ({ id }) => id === 'testEngineLiterals', + ); + const measured = metric.measure([ + { + file: 'src/one.test.mjs', + text: 'const engine = {}; init({ engine: {} });', + }, + { + file: 'scripts/two.test.mjs', + text: 'const engine = () => ({ host: {} });', + }, + { file: 'src/three.test.mjs', text: 'const engine = createFakeEngine();' }, + { file: 'src/runtime.js', text: 'const engine = {};' }, + ]); + assert.equal(measured, 2); + const baseline = Object.fromEntries( + QA_HARNESS_METRICS.map(({ id }) => [id, 0]), + ); + assert.deepEqual( + harnessGrowth({ ...baseline, testEngineLiterals: measured }, baseline).map( + ({ id }) => id, + ), + ['testEngineLiterals'], + ); +}); + test('metrics count scripts once and distinguish helpers from their callers', () => { const measure = (id, ...sources) => QA_HARNESS_METRICS.find((metric) => metric.id === id).measure( diff --git a/scripts/report-qa-harness.mjs b/scripts/report-qa-harness.mjs index b019bc3e0..75cf7e612 100644 --- a/scripts/report-qa-harness.mjs +++ b/scripts/report-qa-harness.mjs @@ -1,7 +1,7 @@ #!/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. + * Browser QA and tests still duplicating the shared harness or fake engine. + * Each metric counts files, so repeated uses in one file count once. * * node scripts/report-qa-harness.mjs * node scripts/report-qa-harness.mjs --json @@ -10,6 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parsers } from 'prettier/plugins/babel.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); export const BASELINE_PATH = path.join( @@ -18,6 +19,80 @@ export const BASELINE_PATH = path.join( 'qa-harness-baseline.json', ); +/** Engine assignments, inline options and engine factory returns, not strings or comments. */ +export function engineLiteralLines(text) { + const lines = []; + const engineName = (name) => + !/Options$/.test(name ?? '') && + /^(?:_?engine(?:[A-Z0-9]\w*)?|\w+Engine(?:[A-Z0-9]\w*)?)$/.test(name ?? ''); + function walk(node, parent, factory) { + if (!node || typeof node !== 'object') return; + if (/Function|ObjectMethod/.test(node.type)) + factory = node.id?.name ?? parent?.id?.name ?? node.key?.name; + if (node.type === 'ObjectExpression') { + const name = + parent?.type === 'VariableDeclarator' + ? parent.id?.name + : parent?.type === 'AssignmentExpression' + ? parent.left?.computed + ? parent.left.property?.value + : (parent.left?.property?.name ?? parent.left?.name) + : parent?.type === 'ObjectProperty' + ? (parent.key?.name ?? parent.key?.value) + : null; + const keys = node.properties.map((property) => property.key?.name); + const host = node.properties.find( + (property) => property.key?.name === 'host', + )?.value; + const globeHost = + host?.type === 'ObjectExpression' && + host.properties.some((property) => + ['canvas', 'container', 'size'].includes(property.key?.name), + ); + const adapterKind = node.properties.some( + (property) => + property.key?.name === 'kind' && + ['cesium', 'navara'].includes(property.value?.value), + ); + const returned = + parent?.type === 'ReturnStatement' || + parent?.type === 'ArrowFunctionExpression'; + // A factory's { engine, ...observations } is a harness, not its engine. + if ( + engineName(name) || + globeHost || + (adapterKind && + keys.some((key) => + ['camera', 'render', 'credits', 'maps'].includes(key), + )) || + (returned && + engineName(factory) && + !keys.includes('engine') && + keys.some((key) => + [ + 'kind', + 'host', + 'camera', + 'frames', + 'render', + 'credits', + 'maps', + ].includes(key), + )) + ) + lines.push(node.loc.start.line); + } + for (const [key, value] of Object.entries(node)) { + if (['loc', 'comments', 'tokens'].includes(key)) continue; + if (Array.isArray(value)) + for (const child of value) walk(child, node, factory); + else if (value && typeof value === 'object') walk(value, node, factory); + } + } + walk(parsers.babel.parse(text)); + return lines; +} + const readsCesiumState = (text) => { const names = [ '__godsEyeView', @@ -78,6 +153,15 @@ export const QA_HARNESS_METRICS = Object.freeze([ ), ).length, }, + { + id: 'testEngineLiterals', + label: 'Test files with their own engine literal', + measure: (files) => + files.filter( + ({ file, text }) => + file.endsWith('.test.mjs') && engineLiteralLines(text).length, + ).length, + }, ]); /** QA scripts, including the shared harness itself, but excluding tests. */ @@ -92,10 +176,29 @@ export function readQaSources(root = ROOT) { })); } +export function readTestSources(root = ROOT) { + return ['build', 'scripts', 'server', 'src'].flatMap((directory) => { + const base = path.join(root, directory); + if (!fs.existsSync(base)) return []; + return fs + .readdirSync(base, { recursive: true }) + .sort() + .filter((name) => name.endsWith('.test.mjs')) + .map((name) => ({ + file: `${directory}/${name.split(path.sep).join('/')}`, + text: fs.readFileSync(path.join(base, name), 'utf8'), + })); + }); +} + export function measureQaHarness(root = ROOT) { const files = readQaSources(root); + const tests = readTestSources(root); return Object.fromEntries( - QA_HARNESS_METRICS.map(({ id, measure }) => [id, measure(files)]), + QA_HARNESS_METRICS.map(({ id, measure }) => [ + id, + measure(id === 'testEngineLiterals' ? tests : files), + ]), ); } diff --git a/src/annotations/annotationSession.test.mjs b/src/annotations/annotationSession.test.mjs index e93ba4b68..6d2159d44 100644 --- a/src/annotations/annotationSession.test.mjs +++ b/src/annotations/annotationSession.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createAnnotationEngine } from './annotationEngine.js'; @@ -69,7 +70,7 @@ test('the marks survive a globe leaving and are drawn again on the next one', as log.length = 0; engine.attachRenderer({ - engine: {}, + engine: createFakeEngine(), renderer: recordingRenderer(log, 'next'), }); assert.deepEqual(log, ['next add Capitol']); @@ -88,8 +89,14 @@ test('detaching twice and attaching over an attached globe release each drawing }); engine.detachRenderer(); engine.detachRenderer(); - engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'b') }); - engine.attachRenderer({ engine: {}, renderer: recordingRenderer(log, 'c') }); + engine.attachRenderer({ + engine: createFakeEngine(), + renderer: recordingRenderer(log, 'b'), + }); + engine.attachRenderer({ + engine: createFakeEngine(), + renderer: recordingRenderer(log, 'c'), + }); assert.deepEqual(log, ['a destroy', 'b destroy']); engine.destroy(); }); diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 7e5370113..28bc0f540 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // Behavioural tests for the draw tool: the module is driven against fake DOM // and viewer doubles and its OBSERVABLE effects are asserted — which lease it // holds, which viewer input actions it borrowed and gave back, what it leaves @@ -975,7 +976,7 @@ test('a page without the Draw controls gets no draw tool', () => { try { let connected = false; const tool = initDrawTool({ - engine: {}, + engine: createFakeEngine(), annotations: fakeAnnotations(), connect: () => { connected = true; diff --git a/src/annotations/hybridAnnotationRenderer.test.mjs b/src/annotations/hybridAnnotationRenderer.test.mjs index 1e071ada7..622f67d8a 100644 --- a/src/annotations/hybridAnnotationRenderer.test.mjs +++ b/src/annotations/hybridAnnotationRenderer.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { installDomClasses } from '../testSupport/domClasses.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -127,10 +128,9 @@ function fakeViewer(lon, lat) { }, }; // The Cesium engine's credits over the viewer's credit display. - const engine = { - credits: createCesiumCredits(viewer).credits, - render: { request: () => scene.requestRender() }, - }; + const engine = createFakeEngine(); + engine.credits = createCesiumCredits(viewer).credits; + engine.render.request = () => scene.requestRender(); enginesOf.set(viewer, engine); return { dataSources, frames, viewer, engine }; } diff --git a/src/annotations/resolverHighTerrain.test.mjs b/src/annotations/resolverHighTerrain.test.mjs index ba8e74825..a2068855d 100644 --- a/src/annotations/resolverHighTerrain.test.mjs +++ b/src/annotations/resolverHighTerrain.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import * as geo from '../geo/index.js'; @@ -9,18 +10,11 @@ test('a depth pick on high terrain close to the camera is kept', () => { const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 4030); const ground = geo.cartesian3.fromDegrees(86.9, 27.9, 4000); const seaLevel = geo.cartesian3.fromDegrees(86.9, 27.9, 0); - const engine = { - host: { size: () => ({ width: 100, height: 100 }) }, - picker: { - pickPositionSupported: true, - pickPosition: () => geo.cartesian3.clone(ground), - pickGlobe: () => geo.cartesian3.clone(ground), - }, - camera: { - livePosition: camera, - pickEllipsoid: () => geo.cartesian3.clone(seaLevel), - }, - }; + const engine = createFakeEngine({ width: 100, height: 100 }); + engine.fake.setPose({ position: camera }); + engine.fake.script('pickPosition', () => geo.cartesian3.clone(ground)); + engine.fake.script('pickGlobe', () => geo.cartesian3.clone(ground)); + engine.fake.script('pickEllipsoid', () => geo.cartesian3.clone(seaLevel)); const picked = pickWorldFromScreen(engine, 0.5, 0.5); assert.ok( Math.abs(picked.height - 4000) < 1, @@ -29,35 +23,35 @@ test('a depth pick on high terrain close to the camera is kept', () => { }); test('a near-camera depth artifact falls back to terrain before sea level', () => { - const engine = { - host: { size: () => ({ width: 100, height: 100 }) }, - picker: { - pickPositionSupported: true, - pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4029), - pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000), - }, - camera: { - livePosition: geo.cartesian3.fromDegrees(86.9, 27.9, 4030), - pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0), - }, - }; + const engine = createFakeEngine({ width: 100, height: 100 }); + engine.fake.setPose({ + position: geo.cartesian3.fromDegrees(86.9, 27.9, 4030), + }); + engine.fake.script('pickPosition', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 4029), + ); + engine.fake.script('pickGlobe', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 4000), + ); + engine.fake.script('pickEllipsoid', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 0), + ); assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1); }); test('a far camera still treats a near-plane depth hit as a miss', () => { // 100 km up: a preview drawn at the near plane reads back 50 m away. const camera = geo.cartesian3.fromDegrees(86.9, 27.9, 100_000); - const engine = { - host: { size: () => ({ width: 100, height: 100 }) }, - picker: { - pickPositionSupported: true, - pickPosition: () => geo.cartesian3.fromDegrees(86.9, 27.9, 99_950), - pickGlobe: () => geo.cartesian3.fromDegrees(86.9, 27.9, 4000), - }, - camera: { - livePosition: camera, - pickEllipsoid: () => geo.cartesian3.fromDegrees(86.9, 27.9, 0), - }, - }; + const engine = createFakeEngine({ width: 100, height: 100 }); + engine.fake.setPose({ position: camera }); + engine.fake.script('pickPosition', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 99_950), + ); + engine.fake.script('pickGlobe', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 4000), + ); + engine.fake.script('pickEllipsoid', () => + geo.cartesian3.fromDegrees(86.9, 27.9, 0), + ); assert.ok(Math.abs(pickWorldFromScreen(engine, 0.5, 0.5).height - 4000) < 1); }); diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index e50935691..cf9cce3fc 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; @@ -111,7 +112,7 @@ test('startup shares one promise and teardown keeps data alive for controls', as test('later components receive the scene engine beside its viewer', async () => { const viewer = { name: 'viewer' }; - const engine = { kind: 'cesium' }; + const engine = createFakeEngine({ kind: 'cesium' }); const seen = []; const receive = (phase) => diff --git a/src/app/controlsSceneFailure.test.mjs b/src/app/controlsSceneFailure.test.mjs index 1a519bf6c..56fd13774 100644 --- a/src/app/controlsSceneFailure.test.mjs +++ b/src/app/controlsSceneFailure.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // A candidate engine that fails after the shell attached to it (a later // part: data, Cockpit clouds) must leave the shell detached, so the working // engine attaches again. @@ -29,7 +30,7 @@ test('a candidate failing in a later part leaves the shell ready for the working const cesium = { kind: 'cesium', viewer: null, - engine: { kind: 'cesium' }, + engine: createFakeEngine({ kind: 'cesium' }), catalog, }; await sceneHost.attach(cesium); @@ -49,7 +50,7 @@ test('a candidate failing in a later part leaves the shell ready for the working const navara = { kind: 'navara', viewer: null, - engine: { kind: 'navara' }, + engine: createFakeEngine({ kind: 'navara' }), catalog, }; await assert.rejects(sceneHost.attach(navara), /layer attachment failed/); diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 633549c96..1b33bd4e9 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -12,15 +13,13 @@ import { createView } from '../view/index.js'; const fakeEngine = () => { const flights = []; - return { - flights, - camera: { - flyTo(options) { - flights.push(options); - options.complete(); - }, - }, + const engine = createFakeEngine(); + engine.flights = flights; + engine.camera.flyTo = (options) => { + flights.push(options); + options.complete(); }; + return engine; }; const fakeLayers = (enabled) => ({ getAll: () => diff --git a/src/app/globeEngine.test.mjs b/src/app/globeEngine.test.mjs index 9f0d3297c..40cd64739 100644 --- a/src/app/globeEngine.test.mjs +++ b/src/app/globeEngine.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -23,7 +24,9 @@ test('the scene engine is built on the viewer and destroyed by the scene lifetim defer: (dispose) => cleanups.push(dispose), createEngine(target, options) { calls.push([target, options]); - return { kind: 'cesium', destroy: () => calls.push('destroy') }; + const engine = createFakeEngine(); + engine.destroy = () => calls.push('destroy'); + return engine; }, }); assert.equal(engine.kind, 'cesium'); diff --git a/src/app/mainStartup.test.mjs b/src/app/mainStartup.test.mjs index c9c3b07b1..91aeeda5a 100644 --- a/src/app/mainStartup.test.mjs +++ b/src/app/mainStartup.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -73,6 +74,8 @@ function boot( }; }, createStandaloneApplication: ({ engineKind }) => { + const engine = createFakeEngine({ kind: engineKind }); + engine.camera.completeFlight = () => flushes.push('flight'); const styleManager = { initialRestorePromise, shareLinkManager: { flush: () => flushes.push('share link') }, @@ -92,9 +95,7 @@ function boot( }), getComponents: () => ({ scene: { - engine: { - camera: { completeFlight: () => flushes.push('flight') }, - }, + engine: engine, }, controls: { styleManager }, }), diff --git a/src/app/navaraToolBackend.test.mjs b/src/app/navaraToolBackend.test.mjs index a178dc215..15f64d1ce 100644 --- a/src/app/navaraToolBackend.test.mjs +++ b/src/app/navaraToolBackend.test.mjs @@ -42,10 +42,8 @@ test('Navara draws the page session board and its switch takes only the drawing' const lifetime = new AbortController(); const layerSession = { lifetime }; const { views, meshes } = fakeViews(); - const engine = { - ...createFakeEngine({ kind: 'navara' }), - extensions: { navara: views }, - }; + const engine = createFakeEngine({ kind: 'navara' }); + engine.extensions = { navara: views }; // As src/app/tools.js attaches the board on the backend's scene. const attach = () => { const cleanups = []; diff --git a/src/celestialRingFocus.test.mjs b/src/celestialRingFocus.test.mjs index 3033bff2c..75d681330 100644 --- a/src/celestialRingFocus.test.mjs +++ b/src/celestialRingFocus.test.mjs @@ -1,21 +1,12 @@ +import { createFakeEngine } from './globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { CelestialRing } from './celestialRing.js'; test('full globe focus uses the engine without a Cesium global', () => { let flight; - const engine = { - host: { canvas: { clientWidth: 1200, clientHeight: 800 } }, - render: { request() {} }, - camera: { - pose: () => ({ - cartographic: { longitude: 0, latitude: 0 }, - heading: 0, - fovy: Math.PI / 3, - }), - flyTo: (options) => { flight = options; }, - }, - }; + const engine = createFakeEngine({ canvas: { clientWidth: 1200, clientHeight: 800 }, fovy: Math.PI / 3 }); + engine.camera.flyTo = (options) => { flight = options; }; const ring = Object.assign(Object.create(CelestialRing.prototype), { _engine: engine, }); diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 129e868b1..b935a0444 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; @@ -3142,11 +3143,8 @@ test('Bhote Koshi republishes its evidence cards to the incoming engine overlay' globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; const first = eventViewer(); const second = eventViewer(); - const engineB = { - id: 'B', - host: { canvas: second.scene.canvas }, - camera: second.camera, - }; + const engineB = createFakeEngine({ canvas: second.scene.canvas }); + engineB.id = 'B'; const hosts = new Map(); const hostFor = (engine) => { const key = engine ?? 'A'; @@ -3216,16 +3214,11 @@ async function navaraEvidenceCard({ sample, signal }) { installEventDom(); globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; const entries = []; - const engine = { - terrain: 3500, - host: { canvas: eventViewer().scene.canvas }, - surface: { - terrainHeight: () => engine.terrain, - sampleTerrainMostDetailed: sample, - }, - maps: { terrainRevision: () => 1 }, - camera: { livePosition: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) }, - }; + const engine = createFakeEngine({ canvas: eventViewer().scene.canvas }); + engine.terrain = 3500; + engine.fake.script('terrainHeight', () => engine.terrain); + engine.fake.script('sampleTerrainMostDetailed', sample); + engine.maps.terrainRevision = () => 1; const layer = buildEventLayer({ eventLoader: async () => event, imageLoader: async (url) => ({ url }), @@ -3330,11 +3323,9 @@ test('Navara evidence anchors without a terrain sample follow the terrain near t assert.ok(Math.abs(height() - pack) < 0.01, 'the pack elevation'); card.position(); assert.ok(Math.abs(height() - pack) < 0.01, 'a far camera keeps it'); - engine.camera.livePosition = Cesium.Cartesian3.fromDegrees( - first.lon, - first.lat, - 6000, - ); + engine.fake.setPose({ + position: Cesium.Cartesian3.fromDegrees(first.lon, first.lat, 6000), + }); engine.terrain = 2000; assert.ok(card.position(), 'the first card is shown'); assert.ok( diff --git a/src/data/bhoteKoshiLocator.edgeCases.test.mjs b/src/data/bhoteKoshiLocator.edgeCases.test.mjs index c28847104..9f959687b 100644 --- a/src/data/bhoteKoshiLocator.edgeCases.test.mjs +++ b/src/data/bhoteKoshiLocator.edgeCases.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // Edge cases for bhote-koshi-locator: a boundary that resolves late, after its // request was cancelled, does not change the live scene; both engines show the // same area and the same target. @@ -42,11 +43,8 @@ function locator(engine) { remove: (source) => shown.delete(source), }, }; - const navara = { - kind: 'navara', - render: { request: (reason) => renders.push(reason) }, - credits: { add() {}, remove() {} }, - }; + const navara = createFakeEngine({ kind: 'navara' }); + navara.render.request = (reason) => renders.push(reason); const layer = createBhoteKoshiLocatorLayer({ boundaryResolver: (signal) => { const lookup = deferred(); @@ -176,11 +174,7 @@ test('edge:bhote-koshi-locator: an engine swap while the boundary resolves lands const enabling = layer.enable(viewer); layer.detachRenderer(); layer.attachRenderer({ - engine: { - kind: 'navara', - render: { request() {} }, - credits: { add() {}, remove() {} }, - }, + engine: createFakeEngine({ kind: 'navara' }), }); lookup.resolve({ ring: LIVE_RING }); assert.equal(await enabling, true); diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index bd8ca4779..510e21f39 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -94,10 +95,9 @@ test('OpenStreetMap has one generic data credit with separate tile and names dis // A Cesium engine's credits over a viewer double's credit display. async function cesiumCreditsEngine(viewer) { const { createCesiumCredits } = await import('../globe/cesium/credits.js'); - return { + return Object.assign(createFakeEngine(), { credits: createCesiumCredits(viewer).credits, - render: { request() {} }, - }; + }); } test('inline OSM attribution persists until its last display owner leaves', async (t) => { diff --git a/src/data/lifecycleScene.test.mjs b/src/data/lifecycleScene.test.mjs index add4e36ae..b17c153e7 100644 --- a/src/data/lifecycleScene.test.mjs +++ b/src/data/lifecycleScene.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { LayerLifecycle } from './lifecycle.js'; @@ -46,7 +47,7 @@ function catalog(...modules) { }; } -const engine = (name) => ({ kind: 'test', name }); +const engine = (name) => Object.assign(createFakeEngine({ kind: 'test' }), { name }); const firstViewer = { name: 'first viewer' }; const secondViewer = { name: 'second viewer' }; @@ -86,6 +87,7 @@ const detaches = (calls) => calls.filter((call) => call[0] === 'detach'); const attaches = (calls) => calls.filter((call) => call[0] === 'attach'); test('one failing detachRenderer leaves the others released and the manager unbound', async () => { + const second = engine('second'); let failures = 1; const broken = sceneLayer('broken', { detach() { @@ -122,16 +124,17 @@ test('one failing detachRenderer leaves the others released and the manager unbo // The next scene binds and both retained layers attach to it. await bindCatalog( manager, - engine('second'), + second, catalog(...modules), ); assert.equal(manager.viewer, secondViewer); - assert.deepEqual(attaches(broken.calls).at(-1), ['attach', engine('second')]); - assert.deepEqual(attaches(after.calls).at(-1), ['attach', engine('second')]); + assert.deepEqual(attaches(broken.calls).at(-1), ['attach', second]); + assert.deepEqual(attaches(after.calls).at(-1), ['attach', second]); assert.equal(manager.isEnabled('broken'), true); }); test('bindCatalog after a failed detach retries the release before attaching', async () => { + const second = engine('second'); let failures = 1; const broken = sceneLayer('broken', { detach() { @@ -146,13 +149,13 @@ test('bindCatalog after a failed detach retries the release before attaching', a await bindCatalog( manager, - engine('second'), + second, catalog(...modules), ); assert.deepEqual(broken.calls.slice(-3), [ ['detach', firstViewer], ['detach', firstViewer], - ['attach', engine('second')], + ['attach', second], ]); // The scene is settled: the next detach releases the second viewer once. await manager.detachScene(); @@ -419,6 +422,7 @@ test('an explicit refresh between scenes ends false when its caller aborts', asy }); test('a layer enabled between scenes initializes on the next scene', async () => { + const second = engine('second'); const late = sceneLayer('late'); const present = sceneLayer('present'); const { manager, modules } = await boundManager(present); @@ -429,9 +433,9 @@ test('a layer enabled between scenes initializes on the next scene', async () => const enabled = manager.setEnabled('late', true, { origin: 'user' }); await tick(); assert.deepEqual(late.calls, [], 'no init against a missing scene'); - await bindCatalog(manager, engine('second'), catalog(...all)); + await bindCatalog(manager, second, catalog(...all)); assert.equal(await enabled, true); - assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', engine('second')]]); + assert.deepEqual(late.calls.slice(0, 2), ['init', ['attach', second]]); }); test('a detach does not deadlock on an enable parked for the next scene', async () => { diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index d151cfbe4..1c383aebf 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { readFileSync } from 'node:fs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; @@ -77,14 +78,14 @@ const { } = militaryAwarenessLayer; const viewerEngines = new WeakMap(); const viewerEngine = (viewer) => { - if (!viewerEngines.has(viewer)) viewerEngines.set(viewer, { - kind: 'cesium', viewer, - render: { - request: () => viewer.scene.requestRender?.(), - setContinuous: (enabled) => { viewer.scene.requestRenderMode = !enabled; }, - isContinuous: () => viewer.scene.requestRenderMode === false, - }, - }); + if (!viewerEngines.has(viewer)) { + const engine = createFakeEngine(); + engine.viewer = viewer; + engine.render.request = () => viewer.scene.requestRender?.(); + engine.render.setContinuous = (enabled) => { viewer.scene.requestRenderMode = !enabled; }; + engine.render.isContinuous = () => viewer.scene.requestRenderMode === false; + viewerEngines.set(viewer, engine); + } return viewerEngines.get(viewer); }; diff --git a/src/data/osmOverlays.test.mjs b/src/data/osmOverlays.test.mjs index d9d49fa9d..0df40972f 100644 --- a/src/data/osmOverlays.test.mjs +++ b/src/data/osmOverlays.test.mjs @@ -12,11 +12,8 @@ import { createFakeEngine } from '../globe/testing/fakeEngine.js'; /** A layer on the fake engine, whose imagery drapes where `surface()` says. */ function setup(overlay = OSM_OVERLAYS[0]) { let surface = 'globe'; - const fake = createFakeEngine({ kind: 'cesium' }); - const engine = { - ...fake, - imagery: { ...fake.imagery, surface: () => surface }, - }; + const engine = createFakeEngine({ kind: 'cesium' }); + engine.imagery.surface = () => surface; const layer = createOsmOverlayLayer(overlay); layer.init(null, { engine }); return { @@ -120,15 +117,15 @@ test('destroy and a renderer detach remove the owned imagery', () => { test('on Navara an overlay drapes through engine.maps and follows the globe', () => { const added = []; - const engineWith = () => ({ - maps: { - addOverlay(options) { - const entry = { options, removed: false }; - added.push(entry); - return () => (entry.removed = true); - }, - }, - }); + const engineWith = () => { + const engine = createFakeEngine({ kind: 'navara' }); + engine.maps.addOverlay = (options) => { + const entry = { options, removed: false }; + added.push(entry); + return () => (entry.removed = true); + }; + return engine; + }; const [seaMarks] = createOsmOverlayLayers(); seaMarks.init(null, { engine: engineWith() }); assert.equal(added.length, 0, 'nothing until enabled'); diff --git a/src/data/pickedContext.test.mjs b/src/data/pickedContext.test.mjs index fa14fccb5..9b932272b 100644 --- a/src/data/pickedContext.test.mjs +++ b/src/data/pickedContext.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -11,7 +12,7 @@ globalThis.CustomEvent ||= class CustomEvent { globalThis.dispatchEvent ||= () => true; const { registerEntityContext } = await import('./contextStore.js'); -const engine = {}; +const engine = createFakeEngine(); const { pickRegistryFor } = await import('./pickRegistry.js'); const { registerPickOwner, unregisterPickOwner, findPickOwner } = pickRegistryFor(engine); const { resolvePickedContext, pickedKindForLayer } = await import('./pickedContext.js'); diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 53579932f..436dd3746 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -1638,7 +1639,8 @@ test('Radio recovery preserves closer altitude and caps extreme full-globe zoom' test('Radio camera navigation yields to every live tracked-entity owner', () => { let following = null; - const engine = { camera: { following: () => following } }; + const engine = createFakeEngine(); + engine.camera.following = () => following; assert.equal(radioCameraNavigationAllowed(engine), true); for (const gevTrackedId of ['flights:abc123', 'military:def456']) { const trackedEntity = { gevTrackedId }; diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 7521043b1..f9941de0d 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -221,9 +221,11 @@ test('cable paths drape on exactly the active surface on every stack', () => { // Boot fires no stack event; the first drape comes from the engine: // photoreal tiles shown ⇔ the globe hidden. - const engine = (photoreal) => ({ - maps: { photorealActive: () => photoreal }, - }); + const engine = (photoreal) => { + const globe = createFakeEngine(); + globe.maps.photorealActive = () => photoreal; + return globe; + }; assert.equal(cableDrapeForEngine(engine(true)), 'tiles'); assert.equal(cableDrapeForEngine(engine(false)), 'terrain'); assert.equal(cableDrapeForEngine(null), 'both'); diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 249526a1b..1d3fc0b6b 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; // src/data/trackedModelRegime.test.mjs // @@ -172,16 +173,12 @@ function viewerAtHeight(height, { tilesLoaded = true, sampleHeight = () => undef trackedEntity: undefined, entities: { remove() {} }, }; - viewer.engine = { - isDestroyed: () => false, - surface: { - tilesLoaded: () => viewer.tileset.tilesLoaded === true, - sampleHeightDetailed(place, { exclude } = {}) { - const h = viewer.scene.sampleHeight(place, exclude); - return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined; - }, - }, - }; + viewer.engine = createFakeEngine(); + viewer.engine.fake.script('tilesLoaded', () => viewer.tileset.tilesLoaded === true); + viewer.engine.fake.script('sampleHeightDetailed', (place, { exclude } = {}) => { + const h = viewer.scene.sampleHeight(place, exclude); + return Number.isFinite(h) ? { height: h, source: 'tiles' } : undefined; + }); return viewer; } diff --git a/src/data/trackedReadout.visualAnchor.test.mjs b/src/data/trackedReadout.visualAnchor.test.mjs index 138cf9117..a8ba20ef6 100644 --- a/src/data/trackedReadout.visualAnchor.test.mjs +++ b/src/data/trackedReadout.visualAnchor.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createTrackedReadout } from './trackedReadout.js'; @@ -19,18 +20,8 @@ test('followed aircraft card anchors to the model the person sees (gevVisualPosi position: () => native.gevDisplayPosition(), }, }; - const listeners = []; - const engine = { - camera: { - following: () => handle, - followChanged: { - addEventListener: (f) => { - listeners.push(f); - return () => {}; - }, - }, - }, - }; + const engine = createFakeEngine(); + engine.camera.following = () => handle; let entries = null; const overlay = { setEntries(_id, e) { diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index dd4a251c1..21b6a0d04 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -214,68 +215,38 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- const changed = eventChannel(); let longitude = -97.74; const cartographic = { latitude: Math.PI / 6, longitude: 0, height: 5000 }; - const camera = { - moveEnd, - changed, - percentageChanged: 0.5, - livePosition: { x: 0, y: 0, z: 0 }, - pose: () => ({ cartographic: { ...cartographic }, heading: 0, pitch: -Math.PI / 2 }), - viewRectangle() { - const west = longitude * Math.PI / 180; - return { - south: 30 * Math.PI / 180, - west, - north: 30.02 * Math.PI / 180, - east: west + 0.02 * Math.PI / 180, - }; - }, - pickEllipsoid: () => undefined, - }; + // A zero-size canvas has no reticle footprint; the view rectangle bounds the fetch. + const engine = createFakeEngine({ + width: 0, + height: 0, + canvas: { clientWidth: 0, clientHeight: 0, width: 0, height: 0 }, + }); + engine.camera.moveEnd = moveEnd; + engine.camera.changed = changed; + engine.camera.pose = () => ({ cartographic: { ...cartographic }, heading: 0, pitch: -Math.PI / 2 }); + engine.fake.script('viewRectangle', () => { + const west = longitude * Math.PI / 180; + return { + south: 30 * Math.PI / 180, + west, + north: 30.02 * Math.PI / 180, + east: west + 0.02 * Math.PI / 180, + }; + }); + engine.fake.script('pickEllipsoid', undefined); + engine.fake.script('worldToWindow', undefined); + engine.frames.preUpdate = preUpdate; + engine.frames.preRender = preRender; + engine.frames.postRender = postRender; + engine.projector.viewProjection = (result = new Array(16).fill(0)) => result; + engine.projector.horizonVisible = () => true; + engine.picker.pickPositionSupported = false; + engine.surface.supported = false; const setLongitude = (next) => { longitude = next; cartographic.longitude = next * Math.PI / 180; }; setLongitude(longitude); - const noSet = () => ({ - set() {}, - setCount() {}, - commit() {}, - replace() {}, - show() {}, - destroy() {}, - }); - // An engine on a zero-size canvas: no pick ray (no reticle footprint), - // so the fetch is bounded by the view rectangle. - const engine = { - kind: 'cesium', - host: { - canvas: { clientWidth: 0, clientHeight: 0, width: 0, height: 0 }, - size: () => ({ width: 0, height: 0 }), - }, - camera, - frames: { preUpdate, preRender, postRender }, - projector: { - worldToWindow: () => undefined, - viewProjection: (result = new Array(16).fill(0)) => result, - horizonVisible: () => true, - }, - picker: { pickPositionSupported: false }, - surface: { - supported: false, - terrainHeight: () => 0, - sampleHeight: () => undefined, - tilesLoaded: () => true, - }, - maps: { - photorealActive: () => false, - current: () => ({ baseImagery: null, photoreal: null }), - }, - render: { request() {} }, - credits: { addStatic: () => () => {} }, - movers: { createSet: noSet }, - ground: { createSet: noSet }, - destroy() {}, - }; performance.clearMarks(); performance.clearMeasures(); diff --git a/src/globe/navara/meshes.test.mjs b/src/globe/navara/meshes.test.mjs index e0a97d116..212e1f58e 100644 --- a/src/globe/navara/meshes.test.mjs +++ b/src/globe/navara/meshes.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -42,16 +43,13 @@ function harness() { }, }; const pickers = new Set(); - const engine = { - render: { request: () => requests++ }, - camera: { livePosition: { x: 0, y: 0, z: 0 }, pickRay: () => null }, - picker: { - pickPosition: () => undefined, - registerMeshPicker(pick) { - pickers.add(pick); - return () => pickers.delete(pick); - }, - }, + const engine = createFakeEngine({ kind: 'navara' }); + engine.render.request = () => requests++; + engine.camera.pickRay = () => null; + engine.fake.script('pickPosition', undefined); + engine.picker.registerMeshPicker = (pick) => { + pickers.add(pick); + return () => pickers.delete(pick); }; const port = createNavaraMeshes(view, { overlay, diff --git a/src/globe/navara/sceneDepth.test.mjs b/src/globe/navara/sceneDepth.test.mjs index a2dabd330..8aa8a6d4f 100644 --- a/src/globe/navara/sceneDepth.test.mjs +++ b/src/globe/navara/sceneDepth.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -140,7 +141,7 @@ test('pack meshes draw in the overlay and test the scene depth', () => { camera: {}, picker: { registerMeshPicker: () => () => {} }, }); - const engine = { meshes: port.meshes, render: { request() {} } }; + const engine = Object.assign(createFakeEngine(), { meshes: port.meshes }); createPackRenderer(engine).image( { id: 'p', placement: { bounds: [0, 0, 0.01, 0.01], height: 300 } }, {}, diff --git a/src/layers/aircraft/sceneBinding.test.mjs b/src/layers/aircraft/sceneBinding.test.mjs index 0745ad75c..9751e1f8d 100644 --- a/src/layers/aircraft/sceneBinding.test.mjs +++ b/src/layers/aircraft/sceneBinding.test.mjs @@ -1,8 +1,8 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createAircraftSceneBinding } from './sceneBinding.js'; import { LayerBindings } from '../../ui/layerBindings.js'; -import { createEngineEvent } from '../../globe/contract.js'; import { readLayerSource } from '../../testSupport/readLayerSource.mjs'; function observedShell(t) { @@ -11,13 +11,14 @@ function observedShell(t) { t.after(() => { globalThis.window = previous; }); - const followChanged = createEngineEvent(); + const engine = createFakeEngine(); + const followChanged = engine.camera.followChanged; const shell = { generation: 1, followChanged, }; const bindings = new LayerBindings({ - engine: { camera: { followChanged } }, + engine: engine, services: {}, operations: { _stampNavigation() { diff --git a/src/layers/aircraft/trackedModelFailure.test.mjs b/src/layers/aircraft/trackedModelFailure.test.mjs index c4bf570c5..630201c05 100644 --- a/src/layers/aircraft/trackedModelFailure.test.mjs +++ b/src/layers/aircraft/trackedModelFailure.test.mjs @@ -111,10 +111,8 @@ function viewerAt(height) { trackedEntity: undefined, entities: { remove() {} }, }; - viewer.engine = { - isDestroyed: () => false, - surface: { tilesLoaded: () => true, sampleHeightDetailed: () => undefined }, - }; + viewer.engine = createFakeEngine(); + viewer.engine.fake.script('sampleHeightDetailed', undefined); return viewer; } diff --git a/src/layers/aircraft/tracking.test.mjs b/src/layers/aircraft/tracking.test.mjs index fb916da51..3c37a18e7 100644 --- a/src/layers/aircraft/tracking.test.mjs +++ b/src/layers/aircraft/tracking.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; @@ -50,7 +51,7 @@ function harness(layer, { groundM = null, track = [] } = {}) { _trailPositions: [], _trailBackfillToken: 0, _renderer: { - engine: {}, + engine: createFakeEngine(), follow: { onChanged: () => () => {} }, }, }; diff --git a/src/layers/alpr/overlayInsecureContext.test.mjs b/src/layers/alpr/overlayInsecureContext.test.mjs index c9c8cdadb..9f811f415 100644 --- a/src/layers/alpr/overlayInsecureContext.test.mjs +++ b/src/layers/alpr/overlayInsecureContext.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // The ALPR paint lane must initialize in an insecure context (plain HTTP over // the HOST=0.0.0.0 LAN opt-in), where crypto.randomUUID does not exist. import { test } from 'node:test'; @@ -34,7 +35,7 @@ test('ALPR overlay init works without crypto.randomUUID', (t) => { }, }, }, - engine: {}, + engine: createFakeEngine(), markers: new Map(), syncMarker() {}, sampleHeight() {}, diff --git a/src/layers/cctv/viewFovy.test.mjs b/src/layers/cctv/viewFovy.test.mjs index 5e48b5fb0..0aca619a3 100644 --- a/src/layers/cctv/viewFovy.test.mjs +++ b/src/layers/cctv/viewFovy.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // CCTV icon thinning must use the camera's real vertical FOV. In a 16:9 // window with Cesium's default fov 60deg the vertical FOV is about 36deg; // main read it from viewer.camera.frustum.fovy. Two cameras 17 m apart seen @@ -33,7 +34,7 @@ test('icon thinning uses the camera vertical FOV, not a pi/3 fallback', () => { distance: () => 2000, cameraPose: () => pose, fovy: () => pose.fovy, - engine: { camera: { pose: () => pose } }, + engine: createFakeEngine({ fovy: FOVY_16_9 }), }; const { refreshHorizonCulling } = createRendering({ state, diff --git a/src/layers/earthquakes/edgeCases.test.mjs b/src/layers/earthquakes/edgeCases.test.mjs index 7a9e2ef92..f78e3e526 100644 --- a/src/layers/earthquakes/edgeCases.test.mjs +++ b/src/layers/earthquakes/edgeCases.test.mjs @@ -82,7 +82,7 @@ test('edge:earthquakes: an expired record is dropped and a duplicate never forks FIXED_SAMPLE, WITH_DUPLICATE, ]); - layer.init(null, { engine: {} }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); assert.equal(await layer.update(), true); assert.equal(await layer.update(), false); diff --git a/src/layers/firms/viewport.test.mjs b/src/layers/firms/viewport.test.mjs index 152e3cf20..99cef43aa 100644 --- a/src/layers/firms/viewport.test.mjs +++ b/src/layers/firms/viewport.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createViewport } from './viewport.js'; @@ -22,11 +23,14 @@ function harness() { const moveEnd = signal(), preRender = signal(), requests = []; + const engine = createFakeEngine(); + engine.camera.moveEnd = moveEnd; + engine.frames.preRender = preRender; const layerState = { _enabled: true, _destroyed: false, _view: { camera: {} }, - _renderer: { engine: { camera: { moveEnd }, frames: { preRender } } }, + _renderer: { engine: engine }, }; const viewport = createViewport({ layerState, diff --git a/src/layers/ghostMaps/edgeCases.test.mjs b/src/layers/ghostMaps/edgeCases.test.mjs index 6bf1e21dc..28dd837d2 100644 --- a/src/layers/ghostMaps/edgeCases.test.mjs +++ b/src/layers/ghostMaps/edgeCases.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { element, kmz, @@ -74,7 +75,7 @@ function harness(t) { let listener = null; const layer = createGhostMapsLayer({ storage: null, - createRenderer: (engine) => fakeRenderer(engine.name, live), + createRenderer: (engine) => fakeRenderer(engine.kind, live), source: { getCatalog: async () => catalog('v1'), getFile: (_id, version) => files.get(version).promise, @@ -84,7 +85,7 @@ function harness(t) { }, }, }); - layer.init(null, { engine: { name: 'cesium' } }); + layer.init(null, { engine: createFakeEngine({ kind: 'cesium' }) }); layer.enable(); t.after(() => layer.destroy()); const file = (version) => { @@ -113,7 +114,7 @@ test('edge:s2-ghostmaps: an edition and engine change mid-parse shows only the s await new Promise(setImmediate); push(catalog('v3')); layer.detachRenderer(); - await layer.attachRenderer({ engine: { name: 'navara' } }); + await layer.attachRenderer({ engine: createFakeEngine({ kind: 'navara' }) }); v2.resolve(kmz('edition two')); v3.resolve(kmz('edition three')); // Let the queued syncs settle. @@ -135,7 +136,7 @@ test('edge:s2-ghostmaps: a bad archive keeps the working edition on the globe', push(catalog('v2')); // The engine changes while the broken file is still on its way. layer.detachRenderer(); - await layer.attachRenderer({ engine: { name: 'navara' } }); + await layer.attachRenderer({ engine: createFakeEngine({ kind: 'navara' }) }); broken.resolve(new Uint8Array([1, 2, 3, 4])); for (let i = 0; i < 20; i += 1) await new Promise(setImmediate); diff --git a/src/layers/ghostMaps/pendingPaint.test.mjs b/src/layers/ghostMaps/pendingPaint.test.mjs index 959540d63..8ede7944b 100644 --- a/src/layers/ghostMaps/pendingPaint.test.mjs +++ b/src/layers/ghostMaps/pendingPaint.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { element, kmz, @@ -58,10 +59,10 @@ for (const action of ['expire', 'disable']) { onClick: () => () => {}, draw(id, features) { const gate = deferred(); - if (engine.name === 'navara') gates.push(gate); + if (engine.kind === 'navara') gates.push(gate); else gate.resolve(); const paint = { - engine: engine.name, + engine: engine.kind, names: [...features.values()].map((feature) => feature.name), dropped: [], ready: gate.promise, @@ -84,7 +85,7 @@ for (const action of ['expire', 'disable']) { getFile: async () => kmz('old event'), }, }); - layer.init(null, { engine: { name: 'cesium' } }); + layer.init(null, { engine: createFakeEngine({ kind: 'cesium' }) }); layer.enable(); t.after(() => layer.destroy()); await layer.update(); @@ -92,7 +93,9 @@ for (const action of ['expire', 'disable']) { // Engine switch: the new drawing is still decoding its icons. layer.detachRenderer(); - const attached = layer.attachRenderer({ engine: { name: 'navara' } }); + const attached = layer.attachRenderer({ + engine: createFakeEngine({ kind: 'navara' }), + }); await new Promise(setImmediate); assert.equal(gates.length, 1); // The event leaves its age window; the expiry pass runs meanwhile. diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 0a0831ddb..0adf169df 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createGnssDisplay } from './render/display.js'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -82,18 +83,15 @@ const threeHealthy = () => [ const flush = () => new Promise((resolve) => setImmediate(resolve)); test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => { - const anchor = defaultGnssViewAnchor({ - host: { size: () => ({ width: 1000, height: 800 }) }, - camera: { - pickEllipsoid(pixel) { - assert.deepEqual(pixel, { x: 500, y: 400 }); - return geo.cartesian3.fromDegrees(-97, 30); - }, - viewRectangle: () => { - throw new Error('centre hit must not compute a rectangle'); - }, - }, + const engine = createFakeEngine({ width: 1000, height: 800 }); + engine.fake.script('pickEllipsoid', (pixel) => { + assert.deepEqual(pixel, { x: 500, y: 400 }); + return geo.cartesian3.fromDegrees(-97, 30); }); + engine.fake.script('viewRectangle', () => { + throw new Error('centre hit must not compute a rectangle'); + }); + const anchor = defaultGnssViewAnchor(engine); assert.ok(Math.abs(anchor.latitude - 30) < 1e-8); assert.ok(Math.abs(anchor.longitude + 97) < 1e-8); }); @@ -189,7 +187,10 @@ test('movement during a pending refresh follows up at the newest view; disable c test('the layer requires a snapshot source and cannot be initialized twice', () => { assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); - assert.throws(() => layer.init(null, { engine: {} }), /already/); + assert.throws( + () => layer.init(null, { engine: createFakeEngine() }), + /already/, + ); }); test('an enabled refresh bins the snapshot into coloured ground cells', async () => { @@ -694,19 +695,15 @@ test('the cells stay out of picks', async () => { test('view anchor falls back to rectangle, then nadir, then no camera', () => { assert.equal(defaultGnssViewAnchor({}), null); const radians = geo.math.toRadians; - const engine = { - host: { size: () => ({ width: 1000, height: 800 }) }, - camera: { - pickEllipsoid: () => undefined, - viewRectangle: () => ({ - west: radians(20), - south: radians(40), - east: radians(22), - north: radians(42), - }), - pose: () => assert.fail('a rectangle must not read the pose'), - }, - }; + const engine = createFakeEngine({ width: 1000, height: 800 }); + engine.fake.script('pickEllipsoid', undefined); + engine.fake.script('viewRectangle', { + west: radians(20), + south: radians(40), + east: radians(22), + north: radians(42), + }); + engine.camera.pose = () => assert.fail('a rectangle must not read the pose'); const rectangleAnchor = defaultGnssViewAnchor(engine); assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10); assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10); diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs index 468d69053..f380865d3 100644 --- a/src/layers/infrastructure/session.test.mjs +++ b/src/layers/infrastructure/session.test.mjs @@ -33,7 +33,7 @@ test('a bundled file delivered between planets keeps multipart IDs and is read o lod() {}, }), ); - layer.init(null, { engine: {} }); + layer.init(null, { engine: createFakeEngine() }); const enabled = layer.enable(); await new Promise(setImmediate); layer.detachRenderer(); @@ -57,9 +57,9 @@ test('a bundled file delivered between planets keeps multipart IDs and is read o await enabled; assert.equal(layer.getAnalystRecords().length, 2); assert.equal(captures.length, 0); - await layer.attachRenderer({ engine: {} }); + await layer.attachRenderer({ engine: createFakeEngine() }); layer.detachRenderer(); - await layer.attachRenderer({ engine: {} }); + await layer.attachRenderer({ engine: createFakeEngine() }); assert.equal(captures[0], captures[1]); assert.deepEqual( captures[0].records.map((r) => r.id), @@ -89,13 +89,13 @@ test('a released scene stops waiting for a stalled dataset load, which stays in lod() {}, }), ); - layer.init(null, { engine: {} }); + layer.init(null, { engine: createFakeEngine() }); const enabled = layer.enable(); await new Promise(setImmediate); layer.detachRenderer(); const scene = new AbortController(); const attaching = layer.attachRenderer({ - engine: {}, + engine: createFakeEngine(), signal: scene.signal, }); scene.abort(); diff --git a/src/layers/installations/controls.test.mjs b/src/layers/installations/controls.test.mjs index 73dbfde4a..cfabb1d8b 100644 --- a/src/layers/installations/controls.test.mjs +++ b/src/layers/installations/controls.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; @@ -26,14 +27,12 @@ test('orbital ground focus reads the drawn height and the engine pick id', () => test('focus frames a selected installation through the globe engine camera', () => { const record = { id: 'osm:node:12', latitude: 70, longitude: 179.8 }; let flight = null; + const engine = createFakeEngine(); + engine.camera.flyToSphere = (sphere, options) => { + flight = { sphere, options }; + }; const state = { - engine: { - camera: { - flyToSphere: (sphere, options) => { - flight = { sphere, options }; - }, - }, - }, + engine: engine, recordById: new Map([[record.id, record]]), }; const controls = createControls({ diff --git a/src/layers/installations/navigation.test.mjs b/src/layers/installations/navigation.test.mjs index 0603c8f90..b58cd62ac 100644 --- a/src/layers/installations/navigation.test.mjs +++ b/src/layers/installations/navigation.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -162,10 +163,12 @@ test('an aborting real ingestion cannot clear the next scheduled view key', asyn test('Navara installation ingestion loads from its engine camera without a viewer', async () => { const { createIngestion } = await import('./ingestion.js'); const rectangle = Cesium.Rectangle.fromDegrees(-123, 37, -122, 38); + const engine = createFakeEngine({ kind: 'navara' }); + engine.fake.script('viewRectangle', () => rectangle); const state = { enabled: true, viewer: null, - engine: { camera: { viewRectangle: () => rectangle } }, + engine: engine, records: [], recordById: new Map(), coverage: { kind: 'viewport' }, @@ -242,18 +245,16 @@ test('the engine camera drives installation viewport requests without a viewer', east: Cesium.Math.toRadians(-178), north: Cesium.Math.toRadians(16), }; + const engine = createFakeEngine(); + engine.fake.script('viewRectangle', () => rectangle); + engine.camera.pose = () => ({ + position: { x: 1, y: 2, z: 3 }, + direction: { x: -1, y: 0, z: 0 }, + }); const state = { enabled: true, viewer: null, - engine: { - camera: { - viewRectangle: () => rectangle, - pose: () => ({ - position: { x: 1, y: 2, z: 3 }, - direction: { x: -1, y: 0, z: 0 }, - }), - }, - }, + engine: engine, }; const loads = []; const parts = { diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs index cdda71d39..9fc445f9c 100644 --- a/src/layers/launches/hover.test.mjs +++ b/src/layers/launches/hover.test.mjs @@ -1,15 +1,13 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { createEngineEvent } from '../../globe/contract.js'; import { createLaunchHover } from './render/display.js'; // What the hover reads of an engine: its canvas, drillPick and camera motion. function hoverEngine(drillPick, style = { cursor: '' }) { - return { - host: { canvas: { style } }, - picker: { drillPick }, - camera: { moveStart: createEngineEvent(), moveEnd: createEngineEvent() }, - }; + const engine = createFakeEngine({ canvas: { style } }); + engine.fake.script('drillPick', drillPick); + return engine; } function fakeTimers() { diff --git a/src/layers/launches/launchPadFrame.test.mjs b/src/layers/launches/launchPadFrame.test.mjs index c42e920ac..b07a09329 100644 --- a/src/layers/launches/launchPadFrame.test.mjs +++ b/src/layers/launches/launchPadFrame.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; @@ -10,12 +11,11 @@ test('selected pad frame uses neutral geometry and the physical orbit epoch', () epoch; const state = { orbitNow: worldTimeFor('satellites'), - _engine: { - camera: { - livePosition: geo.cartesian3.fromDegrees(launch.lon, launch.lat, 500), - height: () => 500, - }, - }, + _engine: createFakeEngine({ + longitude: geo.math.toRadians(launch.lon), + latitude: geo.math.toRadians(launch.lat), + altitude: 500, + }), _enabled: true, _selectedLaunchId: 'qa', _launches: [launch], diff --git a/src/layers/launches/lifecycleSnapshot.test.mjs b/src/layers/launches/lifecycleSnapshot.test.mjs index 6e3fe5615..11c89652c 100644 --- a/src/layers/launches/lifecycleSnapshot.test.mjs +++ b/src/layers/launches/lifecycleSnapshot.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createLifecycle } from './lifecycle.js'; @@ -8,7 +9,7 @@ test('missions preserve the satellite target before their focus flight releases let target = 25544, snapshot = null; const lifecycle = createLifecycle({ - state: { _engine: {} }, + state: { _engine: createFakeEngine() }, services: { render: {} }, getRenderer: () => ({ setVisible() {} }), parts: { @@ -46,7 +47,7 @@ test('missions begin their entry flight while the satellite catalog loads', asyn }); let flew = false; const lifecycle = createLifecycle({ - state: { _engine: {} }, + state: { _engine: createFakeEngine() }, services: { render: {} }, getRenderer: () => ({ setVisible() {} }), parts: { diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs index b3d10b1d2..f3493b095 100644 --- a/src/layers/launches/replay.test.mjs +++ b/src/layers/launches/replay.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createState } from './state.js'; @@ -48,9 +49,7 @@ test('changing replay speed during the countdown keeps its real-time liftoff', ( test('resuming a replay restores its retained chase heading', () => { const { state } = replayRig(); - state._engine = { - frames: { preUpdate: { addEventListener: () => () => {} } }, - }; + state._engine = createFakeEngine(); state._launches = [{ id: 'mission' }]; state._replayCameraLaunchId = 'mission'; state._replayCameraHeading = 1.2; diff --git a/src/layers/localAdsb/models.test.mjs b/src/layers/localAdsb/models.test.mjs index dd1b1dc96..883301e26 100644 --- a/src/layers/localAdsb/models.test.mjs +++ b/src/layers/localAdsb/models.test.mjs @@ -26,7 +26,7 @@ test('ground snapping excludes the aircraft by its engine pick id', async () => }, frustumTester: () => () => true, horizon: () => ({ isPointVisible: () => true }), - engine: {}, + engine: createFakeEngine(), }, }; const exclusions = []; diff --git a/src/layers/recentImagery/index.test.mjs b/src/layers/recentImagery/index.test.mjs index c58b691c0..f282dc0c1 100644 --- a/src/layers/recentImagery/index.test.mjs +++ b/src/layers/recentImagery/index.test.mjs @@ -728,14 +728,12 @@ function failingMapController() { const controller = new MapSourceController({ registry, initialStack: 'photoreal', - engine: { - kind: 'cesium', - render: { request() {} }, + engine: Object.assign(createFakeEngine(), { maps: createCesiumMaps(viewer, { credits: createCesiumCredits(viewer).credits, createImageryLayer: (provider) => ({ provider }), }).maps, - }, + }), onError: () => {}, }); const calls = []; diff --git a/src/layers/satellites/detachedToggle.test.mjs b/src/layers/satellites/detachedToggle.test.mjs index acfc58e29..9efe9c2b2 100644 --- a/src/layers/satellites/detachedToggle.test.mjs +++ b/src/layers/satellites/detachedToggle.test.mjs @@ -93,7 +93,7 @@ test('restored orbit rings receive their current rotation before the first frame }, }); lifecycle.methods.attachRenderer({ - engine: {}, + engine: createFakeEngine(), viewRestored: new Promise(() => {}), }); assert.equal(rotated, 1); diff --git a/src/layers/traffic/deadline.test.mjs b/src/layers/traffic/deadline.test.mjs index c154ebae7..3b1f7810d 100644 --- a/src/layers/traffic/deadline.test.mjs +++ b/src/layers/traffic/deadline.test.mjs @@ -13,21 +13,16 @@ import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // the near plane, as the engines answer). function cameraEngine(scene) { const eye = new C.Cartesian3(); - return { - host: { - size: () => ({ - width: scene.canvas.clientWidth, - height: scene.canvas.clientHeight, - }), - }, - projector: { - worldToWindow(point, result) { - C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye); - if (eye.z >= -scene.camera.frustum.near) return undefined; - return C.SceneTransforms.worldToWindowCoordinates(scene, point, result); - }, - }, - }; + const engine = createFakeEngine({ + width: scene.canvas.clientWidth, + height: scene.canvas.clientHeight, + }); + engine.fake.script('worldToWindow', (point, result) => { + C.Matrix4.multiplyByPoint(scene.camera.viewMatrix, point, eye); + if (eye.z >= -scene.camera.frustum.near) return undefined; + return C.SceneTransforms.worldToWindowCoordinates(scene, point, result); + }); + return engine; } const bounds = { west: 0, east: 0.01, south: 0, north: 0.01 }; diff --git a/src/layers/traffic/footprint.test.mjs b/src/layers/traffic/footprint.test.mjs index c5b868a40..c74b672a3 100644 --- a/src/layers/traffic/footprint.test.mjs +++ b/src/layers/traffic/footprint.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; @@ -35,32 +36,35 @@ test('oblique horizon bearings are bounded and tile caps hold at city latitudes' ]) { // An engine-shaped stub: the camera's pose and its ellipsoid and ray // picks (the sky above the 300 px line), and the canvas. - const engine = { - camera: { - pose: () => ({ - cartographic: geo.cartographic.fromDegrees(lon, lat, 300), - heading: 0, - pitch: -Math.PI / 12, - }), - pickEllipsoid(p) { - if (p.y < 300) return undefined; - return geo.cartesian3.fromDegrees( - lon + (p.x - 640) / 100000, - lat + (800 - p.y) / 50000, - 0, - ); - }, - pickRay: () => ({ - origin: geo.cartesian3.create(), - direction: geo.cartesian3.create( - -Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180), - -Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180), - Math.cos((lat * Math.PI) / 180), - ), - }), - }, - host: { canvas: { width: 1280, height: 800 } }, - }; + const engine = createFakeEngine({ + longitude: geo.math.toRadians(lon), + latitude: geo.math.toRadians(lat), + altitude: 300, + canvas: { width: 1280, height: 800 }, + }); + engine.fake.setPose({ + longitude: geo.math.toRadians(lon), + latitude: geo.math.toRadians(lat), + height: 300, + heading: 0, + pitch: -Math.PI / 12, + }); + engine.fake.script('pickEllipsoid', (p) => { + if (p.y < 300) return undefined; + return geo.cartesian3.fromDegrees( + lon + (p.x - 640) / 100000, + lat + (800 - p.y) / 50000, + 0, + ); + }); + engine.camera.pickRay = () => ({ + origin: geo.cartesian3.create(), + direction: geo.cartesian3.create( + -Math.sin((lat * Math.PI) / 180) * Math.cos((lon * Math.PI) / 180), + -Math.sin((lat * Math.PI) / 180) * Math.sin((lon * Math.PI) / 180), + Math.cos((lat * Math.PI) / 180), + ), + }); const result = createReticleFootprint()(engine, 300); assert.ok(result.coverage.coarse.length <= 16); assert.ok(result.coverage.detail.length <= 16); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 60629a4e5..0110b50d4 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3695,12 +3695,12 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve // Traffic claims through its engine's camera, as bikeshare does: the // viewer's. The stub has what binding its scene subscribes to. const listeners = { addEventListener: () => noop }; - const trafficEngine = { - camera: app.viewer.camera, - frames: { preRender: listeners, postRender: listeners }, - projector: { viewProjection: noop }, - surface: { tilesLoaded: () => false }, - }; + const trafficEngine = createFakeEngine(); + trafficEngine.camera = app.engine.camera; + trafficEngine.frames.preRender = listeners; + trafficEngine.frames.postRender = listeners; + trafficEngine.projector.viewProjection = noop; + trafficEngine.fake.script('tilesLoaded', false); const traffic = trafficLifecycle({ state: { _engine: trafficEngine, _loadGeneration: 0 }, services: { diff --git a/src/layers/wind/edgeCases.test.mjs b/src/layers/wind/edgeCases.test.mjs index 2ae1512ae..efb50ecaa 100644 --- a/src/layers/wind/edgeCases.test.mjs +++ b/src/layers/wind/edgeCases.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // Edge cases for wind: on a fixed // grid direction, speed and legend agree whichever way the centre is picked, // and an empty field is never presented as calm. @@ -24,15 +25,13 @@ const options = { status: 'Model forecast', }; /** Inspect either the current map centre or a captured position. */ -const viaEngine = (snapshot, point) => - inspectWindAtCenter( - snapshot, - { - camera: { pickEllipsoid: () => point }, - host: { canvas: { clientWidth: 800, clientHeight: 600 } }, - }, - options, - ); +const viaEngine = (snapshot, point) => { + const engine = createFakeEngine({ + canvas: { clientWidth: 800, clientHeight: 600 }, + }); + engine.fake.script('pickEllipsoid', point); + return inspectWindAtCenter(snapshot, engine, options); +}; const viaPosition = (snapshot, point) => inspectWindAtCenter(snapshot, null, { ...options, position: point }); const windLine = (reading) => diff --git a/src/layers/wind/inspection.test.mjs b/src/layers/wind/inspection.test.mjs index 1161c1d76..a654b02a6 100644 --- a/src/layers/wind/inspection.test.mjs +++ b/src/layers/wind/inspection.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; @@ -27,10 +28,10 @@ test('center inspection uses a fixed forecast reading without double-converting values: Float32Array.from([20, 20, 20, 20]), }, }; - const engine = { - camera: { pickEllipsoid: () => geo.cartesian3.fromDegrees(0, 0) }, - host: { canvas: { clientWidth: 800, clientHeight: 600 } }, - }; + const engine = createFakeEngine({ + canvas: { clientWidth: 800, clientHeight: 600 }, + }); + engine.fake.script('pickEllipsoid', () => geo.cartesian3.fromDegrees(0, 0)); const result = inspectWindAtCenter(snapshot, engine, { overlay: 'temperature', units: 'km/h', diff --git a/src/layers/wind/inspectionMarker.test.mjs b/src/layers/wind/inspectionMarker.test.mjs index 94d84630d..a1c5a2338 100644 --- a/src/layers/wind/inspectionMarker.test.mjs +++ b/src/layers/wind/inspectionMarker.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker // follows only its captured point, renders in its container, and releases // its frame listener. @@ -47,15 +48,13 @@ function fixture(t) { requestRender() {}, }, }; - const engine = { - host: { canvas: viewer.scene.canvas }, - frames: { postRender: viewer.scene.postRender }, - projector: { - worldToWindow: viewer.scene.cartesianToCanvasCoordinates, - horizonVisible: () => visibility.visible, - }, - render: { request() {} }, - }; + const engine = createFakeEngine({ canvas: viewer.scene.canvas }); + engine.frames.postRender = viewer.scene.postRender; + engine.fake.script( + 'worldToWindow', + viewer.scene.cartesianToCanvasCoordinates, + ); + engine.projector.horizonVisible = () => visibility.visible; const store = useWindInspectionStore(pinia); visibility.visible = true; const owner = createWindInspectionMarker({ diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 366f72c99..d1f11406c 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createCesiumMaps } from '../globe/cesium/maps.js'; import { createCesiumCredits } from '../globe/cesium/credits.js'; import test from 'node:test'; @@ -72,7 +73,7 @@ function fixture(registry, options = {}) { }); const controller = new MapSourceController({ registry, - engine: { kind: 'cesium', render: { request() {} }, maps: mapsOwner.maps }, + engine: Object.assign(createFakeEngine(), { maps: mapsOwner.maps }), onChange: (state) => changes.push(state), ...options, }); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 6f2f8b9c4..f1f72420f 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -2070,7 +2070,7 @@ test('a caught run failure resolves as run-failed, not success (#971)', async () test('a failed scene-pack rebind releases the partial renderer and a stopped shot unblocks', async () => { const director = Object.create(SceneDirector.prototype); const drawn = new Set(); - director.engine = { host: { container: {} } }; + director.engine = createFakeEngine(); director._dataPacks = { setScene(viewer) { if (!viewer) drawn.clear(); @@ -2088,7 +2088,7 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho assert.throws( () => director.attachScene( - { engine: { host: { container: {} } } }, + { engine: createFakeEngine() }, {}, () => true, ), @@ -2100,7 +2100,7 @@ test('a failed scene-pack rebind releases the partial renderer and a stopped sho abort.abort(); assert.equal(await stopped, false); const waiting = director._waitForScene(); - director.engine = { host: {} }; + director.engine = createFakeEngine(); director.styleManager = {}; director.resumeScene(); assert.equal(await waiting, true); diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index dd05eda11..d6af5fc47 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from './globe/testing/fakeEngine.js'; import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { @@ -242,7 +243,9 @@ test('scope appearance follows direct state changes and install/destroy lifecycl setScopeMaskEnabled(false); assert.equal(dom.documentElement.dataset.scopeMask, 'off'); delete dom.documentElement.dataset.scopeMask; - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); assert.equal( dom.documentElement.dataset.scopeMask, 'off', @@ -254,7 +257,9 @@ test('scope appearance follows direct state changes and install/destroy lifecycl undefined, 'destroy clears the document marker', ); - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); assert.equal( dom.documentElement.dataset.scopeMask, 'off', @@ -282,7 +287,9 @@ test('scope appearance follows direct state changes and install/destroy lifecycl test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); // 1x monitor: backing store matches CSS pixels. assert.equal(dom.canvas.width, 1000); assert.equal(dom.canvas.height, 800); @@ -314,7 +321,9 @@ test('a DPR change with no resize still repaints the backing store', () => { test('destroy tears the DPR watch down (no redraw after teardown)', () => { const dom = stubScopeMaskDom({ width: 640, height: 480, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); assert.equal(dom.canvas.width, 640); destroyScopeMask(); dom.setDpr(2); @@ -439,7 +448,9 @@ test('quantization snaps to the repaint grid', () => { test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); const base = getScopeTerminusRepaintCount(); // Parked at true full-globe altitude: the seeded alpha already matches, so @@ -477,7 +488,9 @@ test('repaint gate: only a QUANTIZED step repaints; hovering costs nothing', () test('a full zoom-in gesture costs only a handful of repaints', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); const base = getScopeTerminusRepaintCount(); // 20 Mm → ground in 10 km steps: ~2000 samples across the whole descent, @@ -510,7 +523,9 @@ test('a full zoom-in gesture costs only a handful of repaints', () => { test('an override pins the terminus and null restores the ramp', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); setScopeTerminusOverride(0.97); assert.equal(getScopeTerminusOverride(), 0.97); assert.equal(getScopeTerminusAlpha(), 0.97); @@ -536,7 +551,9 @@ test('an override pins the terminus and null restores the ramp', () => { test('the hard-crop (feather 0) path honors the same terminus alpha', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); updateScopeTerminusForHeight(SCOPE_TERMINUS_FAR_M + 5_000_000); // true full-globe view setScopeMaskFeather(0); // hard crop — the evenodd rect-minus-circle path // The globe-scale seed paint is legitimately in the history, so assert on @@ -583,15 +600,12 @@ function stubScopeEngine(container, heightM) { set.add(listener); return () => set.delete(listener); }; + const engine = createFakeEngine({ container, altitude: height }); + engine.frames.preRender.addEventListener = addTo(preRenderListeners); + engine.camera.moveEnd.addEventListener = addTo(moveEndListeners); + engine.camera.height = () => height; return { - engine: { - host: { container }, - frames: { preRender: { addEventListener: addTo(preRenderListeners) } }, - camera: { - height: () => height, - moveEnd: { addEventListener: addTo(moveEndListeners) }, - }, - }, + engine, setHeight(next) { height = next; }, @@ -748,7 +762,9 @@ test('terminus percents clamp into the supported band; junk is absent, not zero' test('a pinned override is floored to the band at every entry point', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { - installScopeMask({ host: { container: dom.container } }); + installScopeMask( + createFakeEngine({ container: dom.container, altitude: 14_000_000 }), + ); setScopeTerminusOverride(0); // an sce=0 link, or any stale caller assert.equal( getScopeTerminusOverride(), diff --git a/src/standalone/applicationSwitch.test.mjs b/src/standalone/applicationSwitch.test.mjs index 1703c7e7e..13a09ca8c 100644 --- a/src/standalone/applicationSwitch.test.mjs +++ b/src/standalone/applicationSwitch.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // The standalone application is created once; an engine switch detaches and // attaches only its engine-bound parts. A candidate that fails releases its // renderer, and the working engine attaches again on the same parts. @@ -48,7 +49,8 @@ function standalone({ failOpen = null, beforeOpen, beforeRelease, lifecycle = fa createStandaloneScene: async ({ engineKind, defer }) => { await beforeOpen?.(engineKind); if (failOpen === engineKind) throw new Error(`${engineKind} refused`); - const engine = { kind: engineKind, id: opened.length }; + const engine = createFakeEngine({ kind: engineKind }); + engine.id = opened.length; opened.push(engine); defer(async () => { await beforeRelease?.(engine); diff --git a/src/standalone/engineSwitch.test.mjs b/src/standalone/engineSwitch.test.mjs index 5fe929fee..478f2a5ed 100644 --- a/src/standalone/engineSwitch.test.mjs +++ b/src/standalone/engineSwitch.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { createSessionHandoff } from '../app/sessionHandoff.js'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -479,10 +480,7 @@ test('pagehide while the outgoing engine is released starts nothing', async () = test('a slow first frame never rolls back a started engine', async () => { const timers = []; - const engine = { - frames: { postRender: { addEventListener: () => () => {} } }, - render: { request() {} }, - }; + const engine = createFakeEngine(); const h = harness(); let frame; const machine = createEngineSwitch({ @@ -525,21 +523,12 @@ test('the first-frame wait resolves on a frame, on a hidden tab and on timeout', }; let draw; let requests = 0; - const engine = { - frames: { - postRender: { - addEventListener(fn) { - draw = fn; - return () => { - draw = null; - }; - }, - }, - }, - render: { - request: () => requests++, - }, + const engine = createFakeEngine(); + engine.frames.postRender.addEventListener = (fn) => { + draw = fn; + return () => { draw = null; }; }; + engine.render.request = () => requests++; const timers = new Map(); const options = { document: doc, diff --git a/src/standalone/rendererCache.test.mjs b/src/standalone/rendererCache.test.mjs index 7f5141ab3..3f6e68f1d 100644 --- a/src/standalone/rendererCache.test.mjs +++ b/src/standalone/rendererCache.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createRendererCache } from './rendererCache.js'; @@ -51,10 +52,7 @@ function fakeBuild(released) { resume() { this.running = true; }, - engine: { - host: { container, canvas }, - render: { request() {} }, - }, + engine: createFakeEngine({ container, canvas }), }; }; } diff --git a/src/standalone/sceneSwitcher.test.mjs b/src/standalone/sceneSwitcher.test.mjs index 746d76ca2..73cc05af9 100644 --- a/src/standalone/sceneSwitcher.test.mjs +++ b/src/standalone/sceneSwitcher.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createSceneSwitcher } from './sceneSwitcher.js'; @@ -193,7 +194,7 @@ test('the restored camera precedes retained follow and replay', async () => { const app = { getState: () => ({ status: 'ready' }), getComponents: () => ({ - scene: { engine: { kind: current } }, + scene: { engine: createFakeEngine({ kind: current }) }, controls: { styleManager: style }, }), async detachScene() { diff --git a/src/ui/displayPanel.test.mjs b/src/ui/displayPanel.test.mjs index a668271aa..69bfb8654 100644 --- a/src/ui/displayPanel.test.mjs +++ b/src/ui/displayPanel.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // src/ui/displayPanel.test.mjs — the Display panel renders bloom and sharpen // from what VisualSettings publishes, and shows its initial markup before. import '../testSupport/registerDom.mjs'; @@ -145,6 +146,7 @@ test('a publish never rewrites a slider the user is dragging', async (t) => { /** VisualSettings with a stand-in detection engine. */ function detectionSettings(store) { const { owner } = settings(store); + // This is detection state, not a globe engine. const engine = { mode: 'OFF', tuning: { densityPct: 50, allocationStrategy: 'ELASTIC' }, @@ -425,12 +427,7 @@ test('the imported files render what the file import publishes and keep focus', assert.equal($('geo-import-hint').textContent, ''); // A globe engine double the files draw on. - const engine = { - host: { container: document.createElement('div'), canvas: null }, - camera: { moveEnd: { addEventListener: () => () => {} } }, - surface: { tilesLoaded: () => true }, - render: { request() {} }, - }; + const engine = createFakeEngine({ container: document.createElement('div') }); const tool = createGeoFileImport({ drawFile: async () => ({ report: { undrawn: 0 }, diff --git a/src/ui/frameRateMonitor.test.mjs b/src/ui/frameRateMonitor.test.mjs index f5b120d01..c512f28e2 100644 --- a/src/ui/frameRateMonitor.test.mjs +++ b/src/ui/frameRateMonitor.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createFrameRateMonitor } from './frameRateMonitor.js'; @@ -17,15 +18,10 @@ function fixture(t) { addEventListener: (name, handler) => keys.add(handler), removeEventListener: (name, handler) => keys.delete(handler), }; - const engine = { - frames: { - postRender: { - addEventListener(handler) { - frames.add(handler); - return () => frames.delete(handler); - }, - }, - }, + const engine = createFakeEngine(); + engine.frames.postRender.addEventListener = (handler) => { + frames.add(handler); + return () => frames.delete(handler); }; const monitor = createFrameRateMonitor({ engine, documentRef, store }); t.after(() => monitor.destroy()); @@ -108,7 +104,7 @@ test('a page without the title bar gets no readout and keeps the key', () => { let listened = false; const store = { publishFps: () => assert.fail('nothing to show it in') }; const monitor = createFrameRateMonitor({ - engine: { frames: { postRender: { addEventListener: () => () => {} } } }, + engine: createFakeEngine(), documentRef: { getElementById: () => null, addEventListener: () => (listened = true), @@ -130,9 +126,7 @@ test("destroying an earlier monitor leaves a newer one's readout", () => { addEventListener() {}, removeEventListener() {}, }; - const engine = { - frames: { postRender: { addEventListener: () => () => {} } }, - }; + const engine = createFakeEngine(); const first = createFrameRateMonitor({ engine, documentRef, store }); const second = createFrameRateMonitor({ engine, documentRef, store }); const shown = store.fps; diff --git a/src/ui/frameRateView.test.mjs b/src/ui/frameRateView.test.mjs index 3e3537676..0941f2594 100644 --- a/src/ui/frameRateView.test.mjs +++ b/src/ui/frameRateView.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // src/ui/frameRateView.test.mjs — the title bar's FPS readout renders what // createFrameRateMonitor() publishes and toggles with the backquote key. import '../testSupport/registerDom.mjs'; @@ -19,7 +20,7 @@ test('the FPS readout sits in the title bar, hidden until the backquote key', as document.querySelector('#title-bar .frame-rate-readout'); assert.equal(readout(), null, 'no readout without a monitor'); const monitor = createFrameRateMonitor({ - engine: { frames: { postRender: { addEventListener: () => () => {} } } }, + engine: createFakeEngine(), store: useStatusStore(pinia), }); t.after(() => monitor.destroy()); diff --git a/src/ui/geoFileImport.test.mjs b/src/ui/geoFileImport.test.mjs index ab07c56ff..c9ea7896e 100644 --- a/src/ui/geoFileImport.test.mjs +++ b/src/ui/geoFileImport.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // Behavioral tests for DISPLAY ▸ Import: the controller is driven against // fake DOM and engine doubles with an injected loader and drawing, and its // observable effects are asserted — drawings made and destroyed, the rows @@ -65,22 +66,18 @@ class FakeElement { /** A globe engine double: its host, camera moves and render requests. */ function fakeEngine() { const moveEnd = new Set(); - const engine = { + const engine = createFakeEngine({ kind: 'fake', - host: { - container: new FakeElement('div', 'cesiumContainer'), - canvas: new FakeElement('canvas'), - }, - camera: { - moveEnd: { - addEventListener: (fn) => (moveEnd.add(fn), () => moveEnd.delete(fn)), - }, - }, - surface: { tilesLoaded: () => true }, - renders: 0, - render: { request: () => (engine.renders += 1) }, - moveEnd, - }; + container: new FakeElement('div', 'cesiumContainer'), + canvas: new FakeElement('canvas'), + }); + engine.camera.moveEnd.addEventListener = (fn) => ( + moveEnd.add(fn), + () => moveEnd.delete(fn) + ); + engine.renders = 0; + engine.render.request = () => (engine.renders += 1); + engine.moveEnd = moveEnd; return engine; } diff --git a/src/ui/geoFileImportRedraw.test.mjs b/src/ui/geoFileImportRedraw.test.mjs index 4a6bf1c04..235eaae1b 100644 --- a/src/ui/geoFileImportRedraw.test.mjs +++ b/src/ui/geoFileImportRedraw.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // A file removed while its drawing on the next engine is still pending // (icon decode) must not leave that drawing on the globe. import test from 'node:test'; @@ -10,12 +11,7 @@ const el = () => ({ addEventListener() {}, removeEventListener() {}, }); -const engine = () => ({ - host: { container: el(), canvas: el() }, - camera: { moveEnd: { addEventListener: () => () => {} } }, - surface: { tilesLoaded: () => true }, - render: { request() {} }, -}); +const engine = () => createFakeEngine({ container: el(), canvas: el() }); test('removing a file during its redraw destroys the late drawing', async () => { const document = { diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs index 1208c69d6..fe6ccecf4 100644 --- a/src/ui/shareLinks.test.mjs +++ b/src/ui/shareLinks.test.mjs @@ -445,16 +445,13 @@ test('the followed entity is read in the share format, and its offset round-trip gevDisplayPosition: () => target, }, }; - const engine = { - camera: { - pose: () => ({ position: cameraPosition }), - following: () => - viewer.trackedEntity && { - native: viewer.trackedEntity, - target: () => target, - }, - }, - }; + const engine = createFakeEngine(); + engine.fake.setPose({ position: cameraPosition }); + engine.camera.following = () => + viewer.trackedEntity && { + native: viewer.trackedEntity, + target: () => target, + }; const subject = readShareFollowSubject({ engine, nowMs: NOW_MS }); assert.equal(subject.kind, 'military_aircraft'); assert.equal(subject.id, 'ae1234'); diff --git a/src/ui/shellLifetime.test.mjs b/src/ui/shellLifetime.test.mjs index a89637470..3ef02c94f 100644 --- a/src/ui/shellLifetime.test.mjs +++ b/src/ui/shellLifetime.test.mjs @@ -127,12 +127,11 @@ test('camera-entry listeners are removed before any asynchronous layer cleanup', const { owner } = bindings(t); let changes = 0; let trackedCallback; - owner.engine = { camera: { followChanged: { - addEventListener(callback) { - trackedCallback = callback; - return () => { changes += 1; }; - }, - } } }; + owner.engine = createFakeEngine(); + owner.engine.camera.followChanged.addEventListener = (callback) => { + trackedCallback = callback; + return () => { changes += 1; }; + }; owner._stampNavigation = () => { changes += 100; }; owner.observeCamera(); owner.stop(); diff --git a/src/ui/shellSceneSwap.test.mjs b/src/ui/shellSceneSwap.test.mjs index b6ea24d8d..e3064aa4f 100644 --- a/src/ui/shellSceneSwap.test.mjs +++ b/src/ui/shellSceneSwap.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // The shell lives for the page: an engine switch detaches only its // globe-bound parts and attaches them to the next globe, keeping the shell's // session objects (navigation, location search, HUD, share link, display @@ -37,7 +38,7 @@ function fakeShell() { }, }; Object.assign(shell, { - _scene: { viewer: {}, engine: { kind: 'cesium' }, mapStackController: {} }, + _scene: { viewer: {}, engine: createFakeEngine({ kind: 'cesium' }), mapStackController: {} }, _disposed: false, _layerSession: null, services: { @@ -143,7 +144,7 @@ test('the next globe gets new globe-bound parts and the restore runs on it', asy const { shell, session, log } = fakeShell(); await shell.detachScene({ handoff: true }); log.length = 0; - const engine = { kind: 'navara' }; + const engine = createFakeEngine({ kind: 'navara' }); await shell.attachScene({ viewer: null, engine, @@ -176,7 +177,7 @@ test('the next globe gets new globe-bound parts and the restore runs on it', asy assert.equal(shell._navigation, session.navigation); await shell.attachScene({ engine }); await assert.rejects( - shell.attachScene({ engine: { kind: 'cesium' } }), + shell.attachScene({ engine: createFakeEngine({ kind: 'cesium' }) }), /attached to a globe/, ); }); @@ -186,7 +187,7 @@ test('a panel whose layer is another instance in the new catalog is rebuilt', as await shell.detachScene({ handoff: true }); log.length = 0; await shell.attachScene({ - engine: { kind: 'cesium' }, + engine: createFakeEngine({ kind: 'cesium' }), services: { cctvLayer: 'other' }, }); assert.ok(log.includes('_initCctvPanel')); @@ -209,7 +210,7 @@ test('an attachment that throws part-way leaves the shell detached and attachabl throw new Error('overlay host failed'); }; await assert.rejects( - shell.attachScene({ engine: { kind: 'navara' } }), + shell.attachScene({ engine: createFakeEngine({ kind: 'navara' }) }), /overlay host failed/, ); // The shell releases what it built and is detached when that settles. @@ -217,7 +218,7 @@ test('an attachment that throws part-way leaves the shell detached and attachabl assert.equal(shell.engine, null); log.length = 0; shell._initSceneOverlays = () => log.push('_initSceneOverlays'); - const engine = { kind: 'cesium' }; + const engine = createFakeEngine({ kind: 'cesium' }); await shell.attachScene({ engine }); assert.equal(shell.engine, engine); assert.ok(log.includes('shareRestoration.start')); @@ -244,7 +245,7 @@ test('between engines a style key waits for the next engine instead of the relea shell._layerBindings = { observeCamera() {}, stop() {}, disconnect() {} }; shell._visualSettings = { setStyle: (name) => applied.push(name) }; }; - await shell.attachScene({ engine: { kind: 'cesium' } }); + await shell.attachScene({ engine: createFakeEngine({ kind: 'cesium' }) }); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(applied, ['crt']); @@ -262,13 +263,13 @@ test('shell recovery waits for failed attachment cleanup and serializes repeated shell._initSceneOverlays = () => { throw new Error('overlay failed'); }; - const failed = shell.attachScene({ engine: { kind: 'navara' } }); + const failed = shell.attachScene({ engine: createFakeEngine({ kind: 'navara' }) }); const rejected = assert.rejects(failed, /overlay failed/); await Promise.resolve(); const detach = shell.detachScene(); const again = shell.detachScene(); shell._initSceneOverlays = () => {}; - const engine = { kind: 'cesium' }; + const engine = createFakeEngine({ kind: 'cesium' }); const recovered = shell.attachScene({ engine }); await Promise.resolve(); assert.notEqual(shell.engine, engine, 'recovery waits for cleanup'); @@ -288,7 +289,7 @@ test('shell attach waits for a deferred outgoing detach', async () => { release = resolve; }); const leaving = shell.detachScene({ handoff: true }); - const engine = { kind: 'navara' }; + const engine = createFakeEngine({ kind: 'navara' }); const entering = shell.attachScene({ engine }); await Promise.resolve(); assert.notEqual(shell.engine, engine); @@ -302,7 +303,7 @@ test('an engine without Wildlife Sightings gets no card instead of a failed star // The stand-in a catalog gives for a layer the engine leaves out. Object.assign(shell, { services: { wildlifeSightingsLayer: {} }, - _scene: { viewer: null, engine: { kind: 'navara' } }, + _scene: { viewer: null, engine: createFakeEngine({ kind: 'navara' }) }, }); assert.doesNotThrow(() => shell._initWildlifeSightingCard()); assert.equal(shell._wildlifeSightingCard, null); @@ -324,7 +325,7 @@ test('a hosted shell uses the enclosing transition while direct calls wait outsi entered(); return new Promise((resolve) => { release = resolve; }); }; - const next = { engine: { kind: 'navara' }, catalog }; + const next = { engine: createFakeEngine({ kind: 'navara' }), catalog }; const switching = host.run(async (transaction) => { await host.detach({ handoff: true }, transaction); await host.attach(next, transaction); diff --git a/src/ui/stateOwners.test.mjs b/src/ui/stateOwners.test.mjs index d659c8a71..3999fc7fa 100644 --- a/src/ui/stateOwners.test.mjs +++ b/src/ui/stateOwners.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { NavigationController } from './navigationController.js'; @@ -69,7 +70,7 @@ test('share teardown settles its promise, removes gestures and rejects a retaine let stamps = 0; let applies = 0; const owner = new ShareRestoration({ - engine: { host: { canvas } }, + engine: createFakeEngine({ canvas }), navigation: { _beginDeferredNavigation: () => 1, _reassertNavigationHandoff: () => true, @@ -164,12 +165,11 @@ test('on Navara, presets set the Sharpen slider to 80 % and the engine draws it' _sharpenSliderRow: element(), _sharpenSliderValue: element(), }; + const engine = createFakeEngine({ kind: 'navara' }); + engine.render.setSharpen = (options) => sharpen.push(options); const owner = new VisualSettings({ viewer: null, - engine: { - kind: 'navara', - render: { setSharpen: (options) => sharpen.push(options) }, - }, + engine: engine, elements, operations: { _syncShareState() {}, diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 47e073a7e..559dd8b72 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -57,7 +57,7 @@ function fixture() { createStage: (options) => ({ ...options, enabled: true }), }, ); - const engine = { render: cesium.render, isDestroyed: () => false }; + const engine = Object.assign(createFakeEngine(), { render: cesium.render }); const effects = new VisualEffects({ engine, requestRender: (reason) => requests.push(reason), @@ -351,9 +351,8 @@ test('existing baseline and military presets keep one detection default', () => test('an engine sharpens with the same amount scale, only while on and above 0', () => { const sharpen = []; const requests = []; - const engine = { - render: { setSharpen: (options) => sharpen.push(options) }, - }; + const engine = createFakeEngine(); + engine.render.setSharpen = (options) => sharpen.push(options); const effects = new VisualEffects({ engine, requestRender: (reason) => requests.push(reason), diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 446d50038..f3cfb0288 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // Pure-logic unit tests for the voice-lifecycle size guards (Batch 11, M13). // These helpers are DOM/WebRTC-free so they pin the screenshot down-scaling and // payload-byte estimation that keep an oversized dc.send from stranding a turn. @@ -2859,10 +2860,8 @@ test('isBenignViewportDeleteError is false for non-error payloads and junk', () test('hidden document yields no fresh frame — capture must not label a stale canvas Current', async () => { const originalDocument = globalThis.document; let requested = 0; - const engine = { - frames: { postRender: { addEventListener() { return () => {}; } } }, - render: { request() { requested += 1; } }, - }; + const engine = createFakeEngine(); + engine.render.request = () => { requested += 1; }; try { globalThis.document = { hidden: true }; const fresh = await renderFreshCesiumFrame(engine); @@ -2878,10 +2877,9 @@ test('visible document with a rendering scene reports a fresh frame', async () = try { globalThis.document = { hidden: false }; let fire = null; - const engine = { - frames: { postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } } }, - render: { request() { queueMicrotask(() => fire?.()); } }, - }; + const engine = createFakeEngine(); + engine.frames.postRender.addEventListener = (listener) => { fire = listener; return () => { fire = null; }; }; + engine.render.request = () => { queueMicrotask(() => fire?.()); }; const fresh = await renderFreshCesiumFrame(engine); assert.equal(fresh, true); } finally { @@ -2895,10 +2893,9 @@ test('a tab switch during the bounded render wait invalidates freshness', async const doc = { hidden: false }; globalThis.document = doc; let fire = null; - const engine = { - frames: { postRender: { addEventListener(listener) { fire = listener; return () => { fire = null; }; } } }, - render: { request() { doc.hidden = true; queueMicrotask(() => fire?.()); } }, - }; + const engine = createFakeEngine(); + engine.frames.postRender.addEventListener = (listener) => { fire = listener; return () => { fire = null; }; }; + engine.render.request = () => { doc.hidden = true; queueMicrotask(() => fire?.()); }; const fresh = await renderFreshCesiumFrame(engine); assert.equal(fresh, false, 'freshness rechecked after the await'); } finally { diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index d3ff7b45a..039fdf8ae 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; // Cross-feature voice contracts, driven through the real action runner: // one result contract (engine fields → model payload → voice card), shared // pointing for imagery, result-set ownership between OSM and the analyst, @@ -687,7 +688,7 @@ test('an engine switch keeps the last answer for a follow-up on the next engine' build: () => harness({ runnerOptions: { analystMemory, layerEnabledAt } }).runner, }); - run.attach({ engine: { kind: 'cesium' } }); + run.attach({ engine: createFakeEngine({ kind: 'cesium' }) }); const first = await run('analyst_query', { layers: ['flights'], scope: { kind: 'anywhere' }, @@ -695,7 +696,7 @@ test('an engine switch keeps the last answer for a follow-up on the next engine' assert.equal(first.ok, true); // The real runner of the outgoing engine is disposed by the switch. run.detach(); - run.attach({ engine: { kind: 'navara' } }); + run.attach({ engine: createFakeEngine({ kind: 'navara' }) }); const followUp = await run('analyst_query', { layers: ['flights'], followUp: true, diff --git a/src/voice/pointerCrop.test.mjs b/src/voice/pointerCrop.test.mjs index d9a3c21b2..eb8ddd94a 100644 --- a/src/voice/pointerCrop.test.mjs +++ b/src/voice/pointerCrop.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -96,23 +97,16 @@ test('crop: a slow render gets one bounded fresh-frame recovery request', async let listener = null; try { globalThis.document = { hidden: false }; - const engine = { - frames: { - postRender: { - addEventListener(next) { - listener = next; - return () => { - if (listener === next) listener = null; - }; - }, - }, - }, - render: { - request() { - requests += 1; - if (requests === 2) queueMicrotask(() => listener?.()); - }, - }, + const engine = createFakeEngine(); + engine.frames.postRender.addEventListener = (next) => { + listener = next; + return () => { + if (listener === next) listener = null; + }; + }; + engine.render.request = () => { + requests += 1; + if (requests === 2) queueMicrotask(() => listener?.()); }; assert.equal(await renderFreshPointerFrame(engine), true); assert.equal(requests, 2, 'only the pointer path retries once'); @@ -127,13 +121,9 @@ test('crop: a hidden document never starts the recovery request', async () => { let requests = 0; try { globalThis.document = { hidden: true }; - const engine = { - frames: { postRender: { addEventListener: () => () => {} } }, - render: { - request() { - requests += 1; - }, - }, + const engine = createFakeEngine(); + engine.render.request = () => { + requests += 1; }; assert.equal(await renderFreshPointerFrame(engine), false); assert.equal(requests, 0); diff --git a/src/voice/sceneRunner.test.mjs b/src/voice/sceneRunner.test.mjs index 003365129..12e8e0a28 100644 --- a/src/voice/sceneRunner.test.mjs +++ b/src/voice/sceneRunner.test.mjs @@ -1,8 +1,9 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createSceneRunner, engineSwitchingRefusal } from './sceneRunner.js'; -const engine = (kind) => ({ kind }); +const engine = (kind) => createFakeEngine({ kind }); function runnerFor(log) { return (scene) => { diff --git a/src/voice/sceneVoice.test.mjs b/src/voice/sceneVoice.test.mjs index 57d1493bd..7f4d9cf01 100644 --- a/src/voice/sceneVoice.test.mjs +++ b/src/voice/sceneVoice.test.mjs @@ -1,3 +1,4 @@ +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createSceneVoice } from './gevRealtime.js'; @@ -6,11 +7,7 @@ import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; function fakeEngine(kind) { const canvas = new EventTarget(); canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); - return { - kind, - host: { canvas, size: () => ({ width: 0, height: 0 }) }, - camera: { pose: () => ({}) }, - }; + return createFakeEngine({ kind, canvas, width: 0, height: 0 }); } function fixture() {