mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
The frame, draw, camera, query and capture ports move out of the conformance scene renderer into src/render/cesium/ports.js, unchanged. The app's transitional renderer, now src/render/cesium/appRenderer.js, builds the same ports over the full viewer; it still leaves out globe, whenSettled and setTime, which would misreport photoreal tiles and stop the app clock. Disposing the ports now settles a pending renderOnce and detaches the draw sets, so a model that loads after destroy is destroyed rather than added to a destroyed collection. A viewer whose ports fail to build is destroyed with its credits. Checked in a browser: the app boots with one credits element, and all eleven conformance scenes match their references exactly.
358 lines
10 KiB
JavaScript
358 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import * as Cesium from 'cesium';
|
|
import { CONTRACT_VERSION, checkRendererShape } from '../contract/index.js';
|
|
import {
|
|
ReadonlySet,
|
|
cesiumRenderer,
|
|
cesiumViewerOf,
|
|
createCesiumRendererPlugin,
|
|
} from './appRenderer.js';
|
|
|
|
/** A document with just a body, recording what is attached. */
|
|
function fakeDocument() {
|
|
const children = [];
|
|
const make = () => {
|
|
const el = {
|
|
id: '',
|
|
remove() {
|
|
const i = children.indexOf(el);
|
|
if (i >= 0) children.splice(i, 1);
|
|
},
|
|
};
|
|
return el;
|
|
};
|
|
return {
|
|
children,
|
|
createElement: make,
|
|
body: { appendChild: (el) => children.push(el) },
|
|
getElementById: () => null,
|
|
};
|
|
}
|
|
|
|
/** A Cesium event: listeners added, and removed by the returned function. */
|
|
function fakeEvent(listeners) {
|
|
return {
|
|
addEventListener(cb) {
|
|
listeners.add(cb);
|
|
return () => listeners.delete(cb);
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeViewer(log, { failDestroy = false } = {}) {
|
|
let destroyed = false;
|
|
const listeners = new Set();
|
|
return {
|
|
listeners,
|
|
scene: {
|
|
camera: {},
|
|
primitives: { add() {}, remove() {} },
|
|
requestRender() {},
|
|
preUpdate: fakeEvent(listeners),
|
|
preRender: fakeEvent(listeners),
|
|
postRender: fakeEvent(listeners),
|
|
},
|
|
canvas: { tag: 'canvas' },
|
|
resize: () => log.push('resize'),
|
|
isDestroyed: () => destroyed,
|
|
destroy() {
|
|
log.push('destroy');
|
|
if (failDestroy) throw new Error('destroy failed');
|
|
destroyed = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
function withDocument(t, doc) {
|
|
const saved = globalThis.document;
|
|
globalThis.document = doc;
|
|
t.after(() => {
|
|
globalThis.document = saved;
|
|
});
|
|
}
|
|
|
|
test('the default plugin is the Cesium renderer', () => {
|
|
assert.equal(cesiumRenderer.id, 'cesium');
|
|
assert.ok(Object.isFrozen(cesiumRenderer));
|
|
});
|
|
|
|
test('the plugin wraps the viewer it is given, behind the accessor only', async (t) => {
|
|
const doc = fakeDocument();
|
|
withDocument(t, doc);
|
|
const log = [];
|
|
let given;
|
|
let viewer;
|
|
const plugin = createCesiumRendererPlugin({
|
|
createViewer(options) {
|
|
given = options;
|
|
viewer = fakeViewer(log);
|
|
return viewer;
|
|
},
|
|
});
|
|
const container = { tag: 'container' };
|
|
const renderer = await plugin.create({
|
|
container,
|
|
signal: new AbortController().signal,
|
|
});
|
|
assert.equal(given.container, container);
|
|
assert.equal(given.creditContainer.id, 'cesium-credits');
|
|
assert.deepEqual(
|
|
doc.children,
|
|
[given.creditContainer],
|
|
'credits on the page',
|
|
);
|
|
|
|
assert.equal(renderer.id, 'cesium');
|
|
assert.equal(renderer.contractVersion, CONTRACT_VERSION);
|
|
assert.equal(renderer.canvas.tag, 'canvas');
|
|
assert.ok(Object.isFrozen(renderer));
|
|
assert.ok(renderer.capabilities instanceof Set);
|
|
assert.throws(() => renderer.capabilities.add('models'), TypeError);
|
|
assert.throws(() => renderer.capabilities.delete('models'), TypeError);
|
|
assert.throws(() => renderer.capabilities.clear(), TypeError);
|
|
assert.ok(!('viewer' in renderer), 'no engine object is a member');
|
|
assert.equal(cesiumViewerOf(renderer).canvas, renderer.canvas);
|
|
|
|
assert.ok(viewer.listeners.size > 0, 'the ports follow the frame phases');
|
|
assert.equal(typeof renderer.draw.icons, 'function');
|
|
assert.ok(renderer.capabilities.has('models'), 'draw.models is declared');
|
|
|
|
renderer.resize();
|
|
renderer.destroy();
|
|
assert.equal(viewer.listeners.size, 0, 'and stop on destroy');
|
|
assert.deepEqual(log, ['resize', 'destroy']);
|
|
assert.deepEqual(doc.children, [], 'credits leave with the renderer');
|
|
renderer.destroy();
|
|
assert.deepEqual(log, ['resize', 'destroy'], 'destroying twice is safe');
|
|
});
|
|
|
|
test('work pending at destroy settles and lets go of the scene', async (t) => {
|
|
withDocument(t, fakeDocument());
|
|
const viewers = [];
|
|
const plugin = createCesiumRendererPlugin({
|
|
createViewer: () => {
|
|
const viewer = fakeViewer([]);
|
|
viewers.push(viewer);
|
|
return viewer;
|
|
},
|
|
});
|
|
|
|
const renderer = await plugin.create({ container: {} });
|
|
const rendered = renderer.frame.renderOnce();
|
|
renderer.destroy();
|
|
await rendered;
|
|
assert.equal(viewers[0].listeners.size, 0, 'no one-shot render stays');
|
|
await renderer.frame.renderOnce();
|
|
|
|
// A model that finishes loading after destroy is destroyed, not added.
|
|
const load = Promise.withResolvers();
|
|
const saved = Cesium.Model.fromGltfAsync;
|
|
Cesium.Model.fromGltfAsync = () => load.promise;
|
|
t.after(() => {
|
|
Cesium.Model.fromGltfAsync = saved;
|
|
});
|
|
const again = await plugin.create({ container: {} });
|
|
again.draw.models({ id: 'm' }).set([
|
|
{
|
|
id: 'a',
|
|
url: 'a.glb',
|
|
pos: { lon: 0, lat: 0, height: 0 },
|
|
orientation: { heading: 0, pitch: 0, roll: 0 },
|
|
},
|
|
]);
|
|
again.destroy();
|
|
let destroyed = false;
|
|
load.resolve({
|
|
destroy: () => (destroyed = true),
|
|
readyEvent: fakeEvent(new Set()),
|
|
errorEvent: fakeEvent(new Set()),
|
|
});
|
|
await load.promise;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.ok(destroyed, 'the late model is destroyed');
|
|
});
|
|
|
|
test('the credits leave even when the viewer fails to destroy', async (t) => {
|
|
const doc = fakeDocument();
|
|
withDocument(t, doc);
|
|
const plugin = createCesiumRendererPlugin({
|
|
createViewer: () => fakeViewer([], { failDestroy: true }),
|
|
});
|
|
const renderer = await plugin.create({
|
|
container: {},
|
|
signal: new AbortController().signal,
|
|
});
|
|
assert.throws(() => renderer.destroy(), /destroy failed/);
|
|
assert.deepEqual(doc.children, []);
|
|
});
|
|
|
|
test('a viewer that fails to build leaves no credits behind', async (t) => {
|
|
const doc = fakeDocument();
|
|
withDocument(t, doc);
|
|
const plugin = createCesiumRendererPlugin({
|
|
createViewer() {
|
|
throw new Error('no WebGL');
|
|
},
|
|
});
|
|
await assert.rejects(
|
|
plugin.create({ container: {}, signal: new AbortController().signal }),
|
|
/no WebGL/,
|
|
);
|
|
assert.deepEqual(doc.children, []);
|
|
});
|
|
|
|
test('an aborted creation builds nothing', async (t) => {
|
|
const doc = fakeDocument();
|
|
withDocument(t, doc);
|
|
let built = false;
|
|
const plugin = createCesiumRendererPlugin({
|
|
createViewer: () => {
|
|
built = true;
|
|
return fakeViewer([]);
|
|
},
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await assert.rejects(
|
|
plugin.create({ container: {}, signal: controller.signal }),
|
|
{ name: 'AbortError' },
|
|
);
|
|
assert.equal(built, false);
|
|
assert.deepEqual(doc.children, []);
|
|
});
|
|
|
|
test('the transitional renderer lacks exactly the members still to come', async (t) => {
|
|
withDocument(t, fakeDocument());
|
|
const renderer = await createCesiumRendererPlugin({
|
|
createViewer: () => fakeViewer([]),
|
|
}).create({ container: {}, signal: new AbortController().signal });
|
|
// Each phase 2 port shrinks this list; nothing may be faked to shorten it.
|
|
assert.deepEqual(checkRendererShape(renderer).sort(), EXPECTED_MISSING);
|
|
});
|
|
|
|
test('a read-only set keeps the values it was built with', () => {
|
|
const set = new ReadonlySet(['models', 'terrain']);
|
|
assert.deepEqual([...set], ['models', 'terrain']);
|
|
assert.ok(set.has('terrain'));
|
|
assert.throws(() => set.add('x'), TypeError);
|
|
assert.equal(set.size, 2);
|
|
});
|
|
|
|
test('only renderers this plugin made reach a Cesium viewer', () => {
|
|
assert.equal(cesiumViewerOf({ id: 'cesium' }), null);
|
|
assert.equal(cesiumViewerOf({}), null);
|
|
});
|
|
|
|
test('the application refuses a renderer it cannot run on, and destroys it', async (t) => {
|
|
const { createApplicationScene } = await import('../../app/scene.js');
|
|
const { createApplicationRequestServices } =
|
|
await import('../../services/requests.js');
|
|
const destroyed = [];
|
|
const deferred = [];
|
|
const plugin = {
|
|
id: 'other',
|
|
async create(options) {
|
|
assert.ok(options.signal, 'the scene passes its signal');
|
|
return {
|
|
id: 'other',
|
|
destroy: () => {
|
|
destroyed.push('other');
|
|
throw new Error('and it fails to go');
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const saved = { document: globalThis.document, window: globalThis.window };
|
|
globalThis.document = fakeDocument();
|
|
globalThis.window = new EventTarget();
|
|
const warn = console.warn;
|
|
console.warn = () => {};
|
|
t.after(() => {
|
|
for (const fn of deferred.reverse()) fn();
|
|
console.warn = warn;
|
|
globalThis.document = saved.document;
|
|
globalThis.window = saved.window;
|
|
});
|
|
await assert.rejects(
|
|
createApplicationScene({
|
|
renderer: plugin,
|
|
requestServices: createApplicationRequestServices(),
|
|
loaderStatus: { textContent: '' },
|
|
signal: new AbortController().signal,
|
|
defer: (fn) => deferred.push(fn),
|
|
}),
|
|
/other renderer cannot run the application yet/,
|
|
'the refusal is not hidden by the failed destroy',
|
|
);
|
|
assert.deepEqual(destroyed, ['other']);
|
|
});
|
|
|
|
const EXPECTED_MISSING = [
|
|
'camera.cancelFlight',
|
|
'camera.controls',
|
|
'camera.controls.coast',
|
|
'camera.controls.coast.drag',
|
|
'camera.controls.coast.zoom',
|
|
'camera.controls.collision',
|
|
'camera.controls.enableRotate',
|
|
'camera.controls.enableTilt',
|
|
'camera.controls.enableZoom',
|
|
'camera.controls.enabled',
|
|
'camera.controls.maxDistance',
|
|
'camera.controls.minDistance',
|
|
'camera.controls.pitchRange',
|
|
'camera.controls.suspend',
|
|
'camera.controls.zoomFactor',
|
|
'camera.controls.zoomInputs',
|
|
'camera.flyTo',
|
|
'camera.flyToBounds',
|
|
'camera.flying',
|
|
'camera.follow',
|
|
'camera.followFramed',
|
|
'camera.followOffset',
|
|
'camera.followOffset.get',
|
|
'camera.followOffset.set',
|
|
'camera.followed',
|
|
'camera.look',
|
|
'camera.lookAt',
|
|
'camera.matrices',
|
|
'camera.move',
|
|
'camera.on',
|
|
'camera.onCommand',
|
|
'camera.release',
|
|
'camera.rotateAround',
|
|
'camera.setFov',
|
|
'camera.viewRectangle',
|
|
'camera.zoom',
|
|
'draw.atlas',
|
|
'draw.clampState',
|
|
'draw.on',
|
|
'frame.diagnostics',
|
|
'frame.holdContinuous',
|
|
'frame.mode',
|
|
'frame.setTargetFps',
|
|
'frame.setTime',
|
|
'frame.suspend',
|
|
'query.clampToSurface',
|
|
'query.drillPick',
|
|
'query.isOccluded',
|
|
'query.pickGlobe',
|
|
'query.pickPosition',
|
|
'query.pickRay',
|
|
'query.projectMany',
|
|
'query.rayCast',
|
|
'query.sampleHeight',
|
|
'query.sampleHeightNow',
|
|
'query.sampleHeightNowMany',
|
|
'renderer.credits',
|
|
'renderer.effects',
|
|
'renderer.globe',
|
|
'renderer.input',
|
|
'renderer.on',
|
|
'renderer.quality',
|
|
'renderer.readPixels',
|
|
'renderer.releaseUnused',
|
|
'renderer.whenSettled',
|
|
].map((member) => `${member} is missing`);
|