Merge branch 'german/refactor-e3-contract-behaviour' into german/navara-refactor

This commit is contained in:
German Khokhlov committed 2026-11-10 15:27:12 +04:00
commit 33ccef9662
39 files changed
+7247 -5194

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ test('the probe lists, toggles and reads layers through the engine', async () =>
point: { sizePx: 4 },
});
assert.deepEqual(probe.movers(), [
{ id: 'fleet', kind: 'point', count: 0, capacity: 4 },
{ id: 'fleet', kind: 'point', count: 0, capacity: 4, shown: false },
]);
const before = probe.framesDrawn();
engine.fake.frame();
+41 -2
View File
@@ -53,6 +53,7 @@ test('the engine draws the visual styles, bloom and contact sonar, not the cloud
const viewer = createFakeViewer();
const engine = createCesiumEngine(viewer);
assert.deepEqual(engineCapabilities(engine), {
tileErrors: true,
visualStyles: true,
bloom: true,
cloudCover: false,
@@ -199,11 +200,11 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p
for (let index = 0; index < count; index++) frame();
},
}).filter(({ name }) =>
/^(shape|movers|lines|colours|input: picks|surface\.sampleHeightDetailed)\b/.test(
/^(kind|movers|lines|input\.on: picks|surface\.sampleHeightDetailed)\b/.test(
name,
),
);
assert.equal(checks.length, 9);
assert.equal(checks.length, 10);
try {
for (const { name, run } of checks) await t.test(name, run);
} finally {
@@ -211,3 +212,41 @@ test('the contract suite movers and lines checks pass on the Cesium engine (no p
}
assert.equal(scene.primitives.length, 0, 'no collection left behind');
});
test('the camera, clock and failed model suite checks pass on the Cesium adapter', async (t) => {
const { createAppClock } = await import('../../time/clock.js');
let wall = 1_000_000;
const { viewer, camera, engine, frame } = createCameraViewer({
engineOptions: {
createEventHandler: createFakeEventHandlers().create,
appClock: createAppClock({ now: () => wall }),
moverFactories: {
loadModel: async () => {
throw new Error('Model unavailable');
},
},
},
});
viewer.entities = new Cesium.EntityCollection();
const checks = createContractSuite(engine, {
fixtures: { failModel() {} },
waitFrames: async (count) => {
for (let i = 0; i < count; i++) {
wall += 16;
camera.update(Cesium.SceneMode.SCENE3D);
camera._updateCameraChanged();
frame();
}
},
}).filter(({ name }) =>
/^(camera.changed: a pure|camera.following:|clock.frameTime:|MoverSet.failed:)/.test(
name,
),
);
assert.equal(checks.length, 6);
try {
for (const { name, run } of checks) await t.test(name, run);
} finally {
engine.destroy();
}
});
+29 -3
View File
@@ -4,6 +4,7 @@
// shared factories in src/maps, as MapSourceController builds them.
import * as Cesium from 'cesium';
import { createEngineEvent } from '../contract.js';
import { loadPhotorealisticTileset } from '../../maps/google3d.js';
import { createMapScene } from './mapScene.js';
import { ESRI_ATTRIBUTION_HTML, imageryFactoryFor } from './mapImagery.js';
@@ -99,10 +100,29 @@ export function createCesiumMaps(
let destroyed = false;
// Every terrain the globe takes counts, a fallback to the ellipsoid too.
let terrainRevision = 0;
let terrainSource = null;
const errors = createEngineEvent();
const errorRemovers = { tile: null, terrain: null };
function watchErrors(provider, source, kind) {
errorRemovers[kind]?.();
errorRemovers[kind] = provider?.errorEvent?.addEventListener((error) => {
if (destroyed) return;
errors.raiseEvent({
source,
kind,
status: error?.error?.statusCode ?? error?.statusCode ?? error?.status,
});
});
}
const offTerrain =
viewer.scene.globe?.terrainProviderChanged?.addEventListener(
() => terrainRevision++,
);
viewer.scene.globe?.terrainProviderChanged?.addEventListener((provider) => {
terrainRevision++;
watchErrors(
provider ?? viewer.scene.terrainProvider,
terrainSource,
'terrain',
);
});
const startupTileset = () =>
(typeof tilesetRef === 'function' ? tilesetRef() : tilesetRef?.current) ??
@@ -137,11 +157,13 @@ export function createCesiumMaps(
function showBase(source, provider) {
currentBase = source;
watchErrors(provider, source, 'tile');
mapScene.showBaseImagery(provider);
showCredit('imagery', imageryCredit(source));
}
const maps = {
errors,
async setBaseImagery(source) {
const generation = ++generations.display;
const provider = await providerFor(source);
@@ -154,7 +176,9 @@ export function createCesiumMaps(
const generation = ++generations.terrain;
const result = await terrainCreator(source)({ signal: abort.signal });
if (destroyed || generation !== generations.terrain) return;
terrainSource = source;
applyTerrain(viewer, result);
if (result.provider) watchErrors(result.provider, source, 'terrain');
requestRender('map-terrain');
},
async setPhotorealTiles(source) {
@@ -203,6 +227,7 @@ export function createCesiumMaps(
// As MapSourceController activates a 3D stack: no base layer, the
// source's credit, one tileset shown and the globe hidden.
mapScene.removeBaseImagery();
watchErrors(null, null, 'tile');
currentBase = null;
currentTiles = source;
showCredit('photoreal', source.credit);
@@ -232,6 +257,7 @@ export function createCesiumMaps(
destroyed = true;
abort.abort();
offTerrain?.();
for (const remove of Object.values(errorRemovers)) remove?.();
mapScene.destroy();
for (const promise of providers.values())
void promise.then(dispose, () => {});
+24
View File
@@ -411,3 +411,27 @@ test('current names the sources on screen as passed', async () => {
maps.releasePhotorealTiles(tiles);
assert.equal(maps.current().photoreal, null, 'a released source is gone');
});
test('maps.errors bridges terrain failures, follows fallback providers and detaches on destroy', async () => {
const { viewer, maps, destroy } = setup();
const failures = [];
maps.errors.addEventListener((error) => failures.push(error));
const first = { errorEvent: new Cesium.Event() };
const source = { kind: 'keyless', create: () => ({ provider: first }) };
await maps.setTerrain(source);
const revision = maps.terrainRevision();
first.errorEvent.raiseEvent({ error: { statusCode: 503 } });
assert.deepEqual(failures, [{ source, kind: 'terrain', status: 503 }]);
const fallback = { errorEvent: new Cesium.Event() };
viewer.terrainProvider = fallback;
assert.ok(maps.terrainRevision() > revision);
first.errorEvent.raiseEvent({ statusCode: 401 });
fallback.errorEvent.raiseEvent({ statusCode: 429 });
assert.deepEqual(failures, [
{ source, kind: 'terrain', status: 503 },
{ source, kind: 'terrain', status: 429 },
]);
destroy();
assert.equal(first.errorEvent.numberOfListeners, 0);
assert.equal(fallback.errorEvent.numberOfListeners, 0);
});
@@ -0,0 +1,57 @@
import * as Cesium from 'cesium';
import { createCesiumMaps } from '../maps.js';
import { createCesiumSurface } from '../surface.js';
// A provider with deterministic samples, using Cesium's real sampling path.
export function createContractFixtures() {
let read = () => 0;
const provider = {
tilingScheme: new Cesium.GeographicTilingScheme(),
availability: {
computeMaximumLevelAtPosition: (place) => {
read(place);
return 0;
},
},
loadTileDataAvailability() {},
requestTileGeometry: async () => ({
interpolateHeight: (_rectangle, longitude, latitude) =>
read({ longitude, latitude }) ?? NaN,
}),
};
const { surface } = createCesiumSurface({
scene: { terrainProvider: provider },
});
const globe = { show: true, terrainProviderChanged: new Cesium.Event() };
const scene = {
globe,
primitives: { contains: () => false },
requestRender() {},
};
const viewer = {
scene,
imageryLayers: { add() {}, remove() {} },
set terrainProvider(value) {
scene.terrainProvider = value;
globe.terrainProviderChanged.raiseEvent(value);
},
};
const owner = createCesiumMaps(viewer, {
createImageryLayer: (provider) => ({ provider }),
credits: { addStatic: () => () => {} },
});
return {
maps: owner.maps,
terrainSource: { kind: 'keyless', create: () => ({ provider }) },
fallbackTerrain: () => {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
},
destroy: () => owner.destroy(),
terrain: {
surface,
sample: (next) => {
read = next ?? (() => 0);
},
},
};
}
+9 -3
View File
@@ -166,7 +166,7 @@
* @property {any} [native]
* @property {object} [label] FollowOptions.label.
* @property {string} [name] Cesium only: the followed Entity's name, else its
* id (a follow without an Entity has none).
* id (a follow without an Entity has none). @deprecated Compatibility field.
*
* @typedef {'click'|'doubleClick'|'move'|'down'|'up'} InputType
* @typedef {'ctrl'|'shift'|'alt'} InputModifier
@@ -346,7 +346,7 @@
* pickId); undefined for map tiles.
* @property {string} [owner] The mover set or line the pick belongs to.
* @property {number} [instance] The mover instance index.
* @property {any} [primitive] Cesium only, for compatibility.
* @property {any} [primitive] Cesium only, for compatibility. @deprecated Compatibility field.
* @property {any} [content] 3D tile content when map tiles were picked (what
* a surface-pick check reads), else undefined.
* @property {any} [object] The engine's own pick result, for engine-specific code.
@@ -368,6 +368,11 @@
* cannot be built (the current source then stays). Base imagery and
* photoreal tiles share one switch order: the call made last decides what
* shows, and a superseded call settles without showing anything.
* @property {EngineEvent} [errors] With the tileErrors capability, reports
* provider failures as { source, kind: 'tile' | 'terrain', status }, where
* source is the description passed to the setter and status is the HTTP
* status code when known (undefined otherwise). Replaced sources stop
* reporting; engines without provider error events omit this capability.
* @property {(source: object) => Promise<void>} setBaseImagery
* @property {(source: object) => Promise<void>} setTerrain
* @property {(source: object | null) => Promise<void>} setPhotorealTiles
@@ -481,7 +486,7 @@
*
* @typedef {object} GlobeMovers
* @property {(options: MoverSetOptions) => MoverSet} createSet
* @property {() => { id: string, kind: string, count: number, capacity: number }[]} list
* @property {() => { id: string, kind: string, count: number, capacity: number, shown: boolean }[]} list
*
* @typedef {object} LineOptions
* A dynamic polyline (src/globe/lines.js checks and defaults these).
@@ -1093,6 +1098,7 @@ const isNameList = (value) =>
// group whole or not at all (assertGlobeEngine); engineCapabilities says
// which it has.
const OPTIONAL_SHAPE = {
tileErrors: { 'maps.errors': isEngineEvent },
visualStyles: {
'render.visualStyles': isNameList,
'render.setStyle': isFunction,
+7 -4
View File
@@ -1,3 +1,4 @@
import { fakeContractOptions } from './testing/contractFixtures.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -198,9 +199,10 @@ test('assertGlobeEngine accepts a complete engine and names every missing capabi
test('the fake engine passes the engine contract suite', async (t) => {
const engine = createFakeEngine();
const checks = createContractSuite(engine, {
waitFrames: async (count) => engine.fake.frames(count),
...fakeContractOptions(engine),
// The fake's input, as a real pointer would drive it.
pointer: {
...fakeContractOptions(engine).pointer,
click: async (x, y) => engine.fake.input('click', { position: { x, y } }),
move: async (x, y) =>
engine.fake.input('move', {
@@ -214,8 +216,9 @@ test('the fake engine passes the engine contract suite', async (t) => {
},
});
assert.ok(checks.length >= 15);
assert.ok(checks.some(({ name }) => name.startsWith('maps:')));
assert.ok(checks.some(({ name }) => name.startsWith('maps.')));
assert.equal(new Set(checks.map((check) => check.name)).size, checks.length);
for (const { name } of checks) assert.match(name, /^\w+(?:\.\w+)?: /);
for (const check of checks) await t.test(check.name, () => check.run());
});
@@ -305,7 +308,7 @@ test('movers and lines are required', () => {
const names = createContractSuite(engine, {
waitFrames: async () => {},
}).map(({ name }) => name);
for (const prefix of ['movers:', 'lines:', 'colours:', 'ground:'])
for (const prefix of ['movers.', 'lines.', 'ground.'])
assert.ok(
names.some((name) => name.startsWith(prefix)),
prefix,
@@ -334,7 +337,7 @@ test('imagery is required', () => {
const names = createContractSuite(engine, {
waitFrames: async () => {},
}).map(({ name }) => name);
assert.ok(names.some((name) => name.startsWith('imagery:')));
assert.ok(names.some((name) => name.startsWith('imagery.')));
});
test('meshes are required', () => {
+1
View File
@@ -388,6 +388,7 @@ export function createFollowController({
handle.label = updated.label;
handle.offset = null;
handle.previous = null;
followChanged.raiseEvent(handle);
requestRender();
},
};
+1
View File
@@ -422,6 +422,7 @@ export function createMoverRegistry(build) {
kind: options.kind,
count: store.count,
capacity: store.capacity,
shown: store.shows.subarray(0, store.count).some(Boolean),
})),
};
return {
+1
View File
@@ -223,6 +223,7 @@ export function createNavaraMaps(
let tilesSource = null;
const knownTiles = new Set();
// The package exposes no provider error event, so tileErrors is absent.
const maps = {
get baseKind() {
return kindOf(currentBase);
@@ -0,0 +1,36 @@
import { createNavaraMaps } from '../maps.js';
import { createNavaraSurface } from '../surface.js';
// A source with deterministic samples, using the maps and surface adapters.
export async function createContractFixtures() {
let read = () => 0;
const view = {
addSource: () => ({ delete() {} }),
addLayer: () => ({ delete() {}, update() {} }),
forceUpdate() {},
sampleTerrainMostDetailed: async (_source, places) =>
places.map(({ lng, lat }) => ({
height: read({
longitude: (lng * Math.PI) / 180,
latitude: (lat * Math.PI) / 180,
}),
})),
};
const maps = createNavaraMaps(view, { setInterval: () => null });
await maps.maps.setTerrain({ kind: 'keyless' });
const { surface } = createNavaraSurface(
view,
{ retainTileGeometry() {} },
maps.sampleTerrain,
);
return {
maps: maps.maps,
terrain: {
surface,
sample: (next) => {
read = next ?? (() => 0);
},
},
destroy: () => maps.destroy(),
};
}
+1
View File
@@ -82,6 +82,7 @@ const HANDLE_TYPES = {
export function contractCallMembers() {
return [
...engineContractMembers(),
'maps.errors',
...Object.entries(HANDLE_TYPES).flatMap(([type, { members }]) =>
members.map((member) => `${type}.${member}`),
),
@@ -1,14 +1,7 @@
[
"FollowHandle.label",
"FollowHandle.name",
"GlobeMesh.id",
"GroundPulseField.id",
"Line.setColor",
"Line.setModelMatrix",
"Line.show",
"MoverSet.failed",
"maps.terrainRevision",
"render.maxTextureSize",
"surface.raycast",
"surface.sampleTerrainMostDetailed"
"Line.show"
]
+3 -1
View File
@@ -1,3 +1,4 @@
import { fakeContractOptions } from './contractFixtures.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
@@ -10,8 +11,9 @@ test('the contract suite does not leave new members uncalled', async (t) => {
const fake = createFakeEngine();
const { engine, called } = withCallLog(fake);
const checks = createContractSuite(engine, {
waitFrames: async (count) => fake.fake.frames(count),
...fakeContractOptions(engine),
pointer: {
...fakeContractOptions(engine).pointer,
click: async (x, y) => fake.fake.input('click', { position: { x, y } }),
move: async (x, y) =>
fake.fake.input('move', {
+59
View File
@@ -0,0 +1,59 @@
// Deterministic inputs for the same checks used by the browser runner.
export function fakeContractOptions(engine) {
const { fake } = engine;
let consumed = -1;
return {
waitFrames: async (count) => {
for (let i = 0; i < count; i++) {
if (!engine.render.isContinuous() && consumed === fake.renderRequests)
throw new Error('No frame requested');
consumed = fake.renderRequests;
fake.frame();
}
},
pointer: {
click: async (x, y) => fake.input('click', { position: { x, y } }),
move: async (x, y) =>
fake.input('move', {
startPosition: { x: x - 1, y },
endPosition: { x, y },
}),
drag: async (x, y, endX, endY) => {
fake.input('down', { position: { x, y } });
fake.input('move', {
startPosition: { x, y },
endPosition: { x: endX, y: endY },
});
fake.input('up', { position: { x: endX, y: endY } });
},
},
resize: async (width, height) => fake.setSize(width, height),
mapSources: {
imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }],
photoreal: { kind: 'photoreal' },
},
fixtures: {
terrain: {
surface: engine.surface,
sample: (read) =>
read
? fake.script('sampleTerrainMostDetailed', async (places) =>
places.map(read),
)
: fake.unscript('sampleTerrainMostDetailed'),
},
failModel: (id, enabled) =>
enabled
? fake.script(
'moverFailed',
(setId, index) =>
setId === id &&
(engine.kind === 'navara' ||
index === undefined ||
index === 0),
)
: fake.unscript('moverFailed'),
tiles: true,
},
};
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from './fakeEngine.js';
import { fakeContractOptions } from './contractFixtures.js';
import { createContractSuite } from './contractSuite/index.mjs';
import { createContractFixtures as cesiumFixtures } from '../cesium/testing/contractFixtures.js';
import { createContractFixtures as navaraFixtures } from '../navara/testing/contractFixtures.js';
for (const [kind, create] of [
['cesium', cesiumFixtures],
['navara', navaraFixtures],
]) {
test(`${kind} adapters pass deterministic sampling and map error suite checks`, async (t) => {
const fixtures = await create();
const engine = createFakeEngine({ kind });
engine.maps = fixtures.maps;
try {
const checks = createContractSuite(engine, {
...fakeContractOptions(engine),
fixtures,
});
const selected = checks.filter(
({ name }) =>
name.startsWith('maps.errors:') ||
name.startsWith('maps.terrainRevision: changes when falling') ||
name.startsWith('surface.sampleTerrainMostDetailed: preserves') ||
name.startsWith('surface.sampleTerrainMostDetailed: passes'),
);
assert.equal(selected.length, kind === 'cesium' ? 8 : 5);
for (const { name, run } of selected) await t.test(name, run);
} finally {
fixtures.destroy?.();
engine.destroy();
}
});
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+83
View File
@@ -0,0 +1,83 @@
import { ensure } from './helpers.mjs';
export function clockChecks(engine, { until }) {
return [
[
'clock.frameTime: one frame time per frame, latched before preUpdate, never decreasing',
async () => {
const records = [];
const read = (name) =>
records.push({
name,
frame: engine.clock.frameTime(),
now: engine.clock.now(),
});
const removers = ['preUpdate', 'preRender', 'postRender'].map((name) =>
engine.frames[name].addEventListener(() => read(name)),
);
try {
await until(
() =>
records.filter(({ name }) => name === 'postRender').length >= 3,
'three drawn frames',
);
} finally {
for (const remove of removers) remove();
}
ensure(
Number.isFinite(engine.clock.now()) &&
Number.isFinite(engine.clock.frameTime()),
'clock.now or clock.frameTime is not a number',
);
// From the first preUpdate on: every frame event reads the frame time
// its frame's preUpdate read, and that time never goes back.
const start = records.findIndex(({ name }) => name === 'preUpdate');
ensure(start >= 0, 'frames.preUpdate never fired');
let frame = null;
for (const record of records.slice(start)) {
ensure(
record.frame <= record.now,
`${record.name}: frameTime ${record.frame} is after now ${record.now}`,
);
if (record.name === 'preUpdate') {
ensure(
frame === null || record.frame >= frame.frame,
`frame time went back from ${frame?.frame} to ${record.frame}`,
);
frame = record;
continue;
}
ensure(
record.frame === frame.frame,
`${record.name} read frame time ${record.frame}, its frame's is ${frame.frame}`,
);
}
},
],
];
}
export function clockLatchChecks(engine, { settle }) {
return [
[
'clock.frameTime: advances before the next preUpdate',
async () => {
await settle();
const before = engine.clock.frameTime();
let observed;
const remove = engine.frames.preUpdate.addEventListener(() => {
observed ??= engine.clock.frameTime();
});
try {
await settle();
ensure(
observed > before,
`preUpdate read stale frame time ${observed}, previous ${before}`,
);
} finally {
remove();
}
},
],
];
}
+177
View File
@@ -0,0 +1,177 @@
import { ensure } from './helpers.mjs';
import { assertGlobeEngine } from '../../contract.js';
export function shapeChecks(engine) {
return [
[
'kind: the engine has every contract capability',
async () => {
assertGlobeEngine(engine);
},
],
[
'host.size: a positive CSS size',
async () => {
const { width, height } = engine.host.size();
ensure(
width > 0 && height > 0,
`size ${width}x${height} is not positive`,
);
},
],
];
}
export function eventChecks(engine, { settle, until, nadirView }) {
const { camera } = engine;
return [
[
'frames.postRender: removed listeners are not called',
async () => {
await nadirView();
let viaHandle = 0;
let viaRemove = 0;
const remove = camera.moveStart.addEventListener(() => viaHandle++);
const listener = () => viaRemove++;
engine.frames.postRender.addEventListener(listener);
remove();
ensure(
engine.frames.postRender.removeEventListener(listener) === true,
'removeEventListener did not report removal',
);
await nadirView(3e6);
await settle(3);
ensure(
viaHandle === 0 && viaRemove === 0,
'a removed listener was called',
);
},
],
[
'frames.preUpdate: preUpdate, preRender and postRender fire in order with timestamps',
async () => {
const order = [];
const removers = ['preUpdate', 'preRender', 'postRender'].map((name) =>
engine.frames[name].addEventListener((time) =>
order.push([name, time]),
),
);
try {
await until(
() => order.some(([name]) => name === 'postRender'),
'postRender',
);
} finally {
for (const remove of removers) remove();
}
const firstPost = order.findIndex(([name]) => name === 'postRender');
const frame = order.slice(0, firstPost + 1);
const index = (name) => frame.map(([entry]) => entry).lastIndexOf(name);
ensure(index('preRender') >= 0, 'preRender did not precede postRender');
ensure(index('preUpdate') >= 0, 'preUpdate did not precede postRender');
ensure(
index('preUpdate') < index('preRender'),
'preUpdate ran after preRender',
);
for (const [name, time] of order)
ensure(
Number.isFinite(time),
`${name} timestamp ${time} is not a number`,
);
},
],
];
}
export function creditChecks(engine) {
return [
[
'credits.addStatic: returns an idempotent remover',
async () => {
const remove = engine.credits.addStatic('Contract check', {
onScreen: false,
});
ensure(typeof remove === 'function', 'addStatic returned no remover');
remove();
remove();
},
],
];
}
export function listenerChecks(engine, { settle, until }) {
return [
[
'frames.postRender: adding a listener twice registers it once',
async () => {
let once = 0;
let twice = 0;
const reference = () => once++;
const listener = () => twice++;
const removers = [
engine.frames.postRender.addEventListener(reference),
engine.frames.postRender.addEventListener(listener),
engine.frames.postRender.addEventListener(listener),
];
try {
await until(() => once >= 2, 'postRender');
} finally {
for (const remove of removers) remove();
}
ensure(
twice === once,
`a listener added twice fired ${twice} times in ${once} frames`,
);
removers[1]();
removers[2]();
await settle(2);
ensure(twice === once, 'a removed listener still fired');
},
],
[
'frames.postRender: removal during dispatch skips the listener; re-adding keeps it',
async () => {
const event = engine.frames.postRender;
const seen = [];
const victim = () => seen.push('victim');
const survivor = () => seen.push('survivor');
let dispatches = 0;
const remover = () => {
dispatches++;
if (dispatches !== 1) return;
event.removeEventListener(victim);
event.removeEventListener(survivor);
event.addEventListener(survivor);
};
event.addEventListener(remover);
event.addEventListener(victim);
event.addEventListener(survivor);
try {
await until(() => dispatches >= 3, 'three postRender dispatches');
} finally {
for (const listener of [remover, victim, survivor])
event.removeEventListener(listener);
}
ensure(
!seen.includes('victim'),
'a listener removed during dispatch ran',
);
ensure(
seen.includes('survivor'),
'a listener removed and re-added during dispatch was lost',
);
},
],
];
}
export function lifetimeChecks(engine) {
return [
[
'isDestroyed: false while the engine lives',
async () => {
ensure(engine.isDestroyed() === false, 'a live engine is destroyed');
},
],
];
}
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
import * as geo from '../../../geo/index.js';
const { cartesian2, cartesian3, cartographic, ellipsoid, math, matrix4 } = geo;
const throwsType = (run, label) => {
let error = null;
try {
run();
} catch (caught) {
error = caught;
}
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
};
const like = (colour, data, i) =>
colour.every(
(channel, k) => k > 2 || Math.abs(data[i + k] - channel * 255) <= 40,
);
const MAGENTA = [255, 0, 255];
const GREEN = [0, 255, 0];
const nearColour = (actual, expected, tolerance = 40) =>
actual.every((value, k) => Math.abs(value - expected[k]) <= tolerance);
const show = (colour) => colour.map((value) => value.toFixed(0)).join(',');
const screenOf = (engine, lon, lat) =>
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
const meanAt = (engine, readRegion, lon, lat, half) => {
const { x, y } = screenOf(engine, lon, lat);
const data = readRegion(
Math.round(x) - half,
Math.round(y) - half,
2 * half,
2 * half,
);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
return mean.map((sum) => sum / (data.length / 4));
};
const WGS84 = ellipsoid.WGS84;
class ContractError extends Error {
constructor(message) {
super(message);
this.name = 'ContractError';
}
}
// The radial ground look's disc in the contract checks, a launch pad's radius.
const RADIAL_RADIUS_M = 500;
// The camera height that shows that disc and its rim a few pixels wide.
const RADIAL_VIEW_M = 4000;
function ensure(condition, message) {
if (!condition) throw new ContractError(message);
}
function near(actual, expected, tolerance, label) {
ensure(
typeof actual === 'number' && Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} is not within ${tolerance} of ${expected}`,
);
}
// Smallest difference between two angles, in radians.
const angleBetween = (a, b) => Math.abs(math.negativePiToPi(a - b));
const isCartesian3 = (value) =>
value != null &&
Number.isFinite(value.x) &&
Number.isFinite(value.y) &&
Number.isFinite(value.z);
// The local east/north/up axes at a position.
function localAxes(position) {
const frame = geo.transforms.eastNorthUpToFixedFrame(position, WGS84);
return {
frame,
east: cartesian3.create(frame[0], frame[1], frame[2]),
north: cartesian3.create(frame[4], frame[5], frame[6]),
up: cartesian3.create(frame[8], frame[9], frame[10]),
};
}
const inRange = (value, low, high, label) =>
ensure(
typeof value === 'number' && value >= low && value < high,
`${label} ${value} is outside [${low}, ${high})`,
);
const unit = (vector) => cartesian3.normalize(vector, cartesian3.create());
const along = (origin, direction, distance) =>
cartesian3.add(
origin,
cartesian3.multiplyByScalar(direction, distance, cartesian3.create()),
cartesian3.create(),
);
export {
cartesian2,
cartesian3,
cartographic,
ellipsoid,
math,
matrix4,
throwsType,
like,
MAGENTA,
GREEN,
nearColour,
show,
screenOf,
meanAt,
WGS84,
RADIAL_RADIUS_M,
RADIAL_VIEW_M,
ensure,
near,
angleBetween,
isCartesian3,
localAxes,
inRange,
unit,
along,
};
+727
View File
@@ -0,0 +1,727 @@
import {
ellipsoid,
throwsType,
MAGENTA,
GREEN,
nearColour,
show,
screenOf,
meanAt,
ensure,
} from './helpers.mjs';
export function imageryChecks(
engine,
{ settle, until, nadirView, readRegion },
) {
const { imagery } = engine;
// A flat colour as RGBA bytes (no canvas needed), size × size texels.
const flat = (rgba, size = 8) => {
const data = new Uint8Array(size * size * 4);
for (let i = 0; i < data.length; i += 4) data.set(rgba, i);
return { width: size, height: size, data };
};
const box = { west: 19, south: 9.5, east: 20, north: 10.5 };
const listed = (id) => imagery.list().find((entry) => entry.id === id);
return [
[
'imagery.createLayer: layers are created, listed, restyled, ordered and destroyed',
async () => {
const source = { kind: 'image', image: flat([255, 0, 255, 255]) };
throwsType(
() =>
imagery.createLayer({ id: 'contract-bad', source: { kind: 'x' } }),
'an unknown source kind',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: {
kind: 'tiles',
url: 'https://example.test/{z}/{x}.png',
},
}),
'a template without {y}',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: {
kind: 'image',
image: { width: 2, height: 2, data: new Uint8Array(3) },
},
}),
'RGBA bytes of the wrong length',
);
throwsType(
() => imagery.createLayer({ id: 'contract-bad', source, alpha: 2 }),
'an alpha above 1',
);
throwsType(
() =>
imagery.createLayer({ id: 'contract-bad', source, split: 'up' }),
'an unknown split',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: {
...source,
rectangle: { west: 0, south: 5, east: 1, north: 4 },
},
}),
'a rectangle upside down',
);
ensure(!listed('contract-bad'), 'a refused layer is listed');
ensure(
['globe', 'tiles', 'none'].includes(imagery.surface()),
`surface() ${imagery.surface()}`,
);
const top = imagery.createLayer({
id: 'contract-imagery-top',
source,
order: 2,
});
const bottom = imagery.createLayer({
id: 'contract-imagery-bottom',
source: {
kind: 'image',
image: flat([0, 255, 0, 255]),
rectangle: box,
},
alpha: 0.5,
show: false,
split: 'left',
});
const split = imagery.splitPosition();
try {
let duplicate = null;
try {
imagery.createLayer({ id: 'contract-imagery-top', source });
} catch (error) {
duplicate = error;
}
ensure(duplicate, 'a second live layer with the same id');
ensure(top.id === 'contract-imagery-top', `id ${top.id}`);
const ids = imagery.list().map(({ id }) => id);
ensure(
ids.indexOf('contract-imagery-bottom') <
ids.indexOf('contract-imagery-top'),
`list() is not in draw order: ${ids.join(', ')}`,
);
ensure(
listed('contract-imagery-bottom')?.kind === 'image' &&
listed('contract-imagery-bottom').shown === false &&
listed('contract-imagery-bottom').alpha === 0.5 &&
listed('contract-imagery-bottom').split === 'left' &&
listed('contract-imagery-top').order === 2,
`list(): ${JSON.stringify(imagery.list())}`,
);
ensure(
bottom.shown() === false &&
bottom.alpha() === 0.5 &&
bottom.split() === 'left',
'the options are not the layer state',
);
bottom.show(true);
bottom.setAlpha(0.25);
bottom.setSplit('right');
ensure(
bottom.shown() === true &&
bottom.alpha() === 0.25 &&
bottom.split() === 'right' &&
listed('contract-imagery-bottom').alpha === 0.25,
'a restyle is not reported',
);
throwsType(() => bottom.setAlpha(-1), 'a negative alpha');
throwsType(() => bottom.setSplit('down'), 'an unknown side');
throwsType(
() => bottom.setSource({ kind: 'image' }),
'an empty source',
);
ensure(
bottom.source().kind === 'image',
'a refused source replaced it',
);
bottom.setSource({
kind: 'tiles',
url: 'https://tiles.invalid/{z}/{x}/{y}.png',
maxLevel: 3,
rectangle: box,
});
ensure(
bottom.source().kind === 'tiles' &&
listed('contract-imagery-bottom').kind === 'tiles',
'setSource() did not replace the source',
);
imagery.setSplitPosition(0.25);
ensure(imagery.splitPosition() === 0.25, 'the split position');
throwsType(
() => imagery.setSplitPosition(1.5),
'a split outside 0..1',
);
await until(() => top.ready(), 'the image layer ready');
await settle();
} finally {
imagery.setSplitPosition(split);
top.destroy();
bottom.destroy();
}
ensure(!listed('contract-imagery-top'), 'a destroyed layer is listed');
ensure(!top.ready(), 'a destroyed layer is ready');
top.destroy();
let refused = null;
try {
top.setAlpha(1);
} catch (error) {
refused = error;
}
ensure(refused, 'a destroyed layer took a restyle');
},
],
[
'imagery.createLayer: images draw on the ground by alpha, show, order and split (pixels)',
async () => {
if (!readRegion) return;
await nadirView(1e6);
// The mean colour in a box centred on a ground point.
// The map's own tiles loaded: what lies under the layers holds still.
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
const base = meanAt(engine, readRegion, 19.5, 10, 6);
ensure(
!nearColour(base, MAGENTA),
'the globe is already magenta there',
);
const magenta = imagery.createLayer({
id: 'contract-paint-magenta',
source: {
kind: 'image',
image: flat([...MAGENTA, 255]),
rectangle: box,
},
});
const layers = [magenta];
try {
await until(() => magenta.ready(), 'the magenta layer ready');
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the magenta layer drawn',
);
ensure(
nearColour(meanAt(engine, readRegion, 21, 10, 6), base, 60) ||
!nearColour(meanAt(engine, readRegion, 21, 10, 6), MAGENTA),
'the layer spills outside its rectangle',
);
// Alpha: at 0 the globe under it, at 0.5 half way to magenta.
magenta.setAlpha(0);
await until(
() => !nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'alpha 0 drawn',
);
await settle(2);
const below = meanAt(engine, readRegion, 19.5, 10, 6);
magenta.setAlpha(0.5);
const half = below.map((value, k) => (value + MAGENTA[k]) / 2);
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), half, 30),
`alpha 0.5 drawn (${show(half)} wanted)`,
);
magenta.setAlpha(1);
magenta.show(false);
await until(
() =>
nearColour(meanAt(engine, readRegion, 19.5, 10, 6), below, 30),
'the hidden layer gone',
);
magenta.show(true);
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the layer shown again',
);
// Order: a higher order draws on top, a lower one under it.
const green = imagery.createLayer({
id: 'contract-paint-green',
source: {
kind: 'image',
image: flat([...GREEN, 255]),
rectangle: box,
},
order: 1,
});
layers.push(green);
const lower = imagery.createLayer({
id: 'contract-paint-under',
source: {
kind: 'image',
image: flat([0, 255, 255, 255]),
rectangle: box,
},
order: -1,
});
layers.push(lower);
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the higher order on top',
);
green.destroy();
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), MAGENTA),
'the destroyed layer gone',
);
// A new source in place: the same layer, another colour.
magenta.setSource({
kind: 'image',
image: flat([...GREEN, 255]),
rectangle: box,
});
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 6), GREEN),
'the new source drawn',
);
magenta.setSource({
kind: 'image',
image: flat([...MAGENTA, 255]),
rectangle: box,
});
lower.destroy();
// Split: a left layer draws only left of the split position.
const width = engine.host.size().width;
const middle = screenOf(engine, 19.5, 10).x;
imagery.setSplitPosition(middle / width);
magenta.setSplit('left');
const left = screenOf(engine, 19.25, 10);
const right = screenOf(engine, 19.75, 10);
const at = ({ x, y }) => {
const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6);
const mean = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) mean[k] += data[i + k] / 36;
return mean;
};
await until(
() =>
nearColour(at(left), MAGENTA) && !nearColour(at(right), MAGENTA),
`the left split drawn (left ${show(at(left))}, right ${show(at(right))})`,
);
magenta.setSplit('right');
await until(
() =>
!nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the right split drawn',
);
magenta.setSplit('none');
await until(
() =>
nearColour(at(left), MAGENTA) && nearColour(at(right), MAGENTA),
'the unsplit layer drawn',
);
} finally {
imagery.setSplitPosition(0.5);
for (const layer of layers) layer.destroy();
}
await until(
() =>
[MAGENTA, GREEN, [0, 255, 255]].every(
(colour) =>
!nearColour(meanAt(engine, readRegion, 19.5, 10, 6), colour),
),
'every layer gone',
);
},
],
[
'imagery.createLayer: tile schemes, transforms, staged sources and raised layers are checked and kept',
async () => {
const magenta = { kind: 'image', image: flat([255, 0, 255, 255]) };
const green = { kind: 'image', image: flat([0, 255, 0, 255]) };
const template = {
kind: 'tiles',
url: 'https://tiles.invalid/{z}/{x}/{y}.png',
maxLevel: 3,
rectangle: box,
};
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: { ...template, scheme: 'polar' },
}),
'an unknown tile scheme',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: { ...template, transform: 'mask' },
}),
'a transform that is not a function',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: magenta,
raise: -1,
}),
'a raise below the ellipsoid',
);
throwsType(
() =>
imagery.createLayer({
id: 'contract-bad',
source: template,
raise: 6000,
}),
'a raised tiles layer',
);
ensure(!listed('contract-bad'), 'a refused layer is listed');
const geographic = imagery.createLayer({
id: 'contract-imagery-geographic',
source: { ...template, scheme: 'geographic', transform: (i) => i },
});
const staged = imagery.createLayer({
id: 'contract-imagery-staged',
source: { ...magenta, rectangle: box },
});
const raisedLayer = imagery.createLayer({
id: 'contract-imagery-raised',
source: { ...magenta, rectangle: box },
raise: 6000,
});
// A promise's outcome, read frame by frame.
const outcome = (promise) => {
const result = { value: undefined };
promise.then((value) => (result.value = value));
return result;
};
try {
ensure(
geographic.source().scheme === 'geographic' &&
typeof geographic.source().transform === 'function',
'the tile scheme or transform is not kept',
);
ensure(
listed('contract-imagery-raised') &&
raisedLayer.raise() === 6000 &&
staged.raise() === null,
`raise(): ${raisedLayer.raise()}, ${staged.raise()}`,
);
throwsType(
() => staged.setDetail({ image: flat([0, 0, 0, 255]) }),
'a detail on a draped layer',
);
throwsType(
() => raisedLayer.setSource(template),
'tiles on a raised layer',
);
throwsType(
() =>
raisedLayer.setDetail({
image: flat([0, 0, 0, 255]),
rectangle: { west: 170, south: 0, east: -170, north: 1 },
}),
'a detail across the antimeridian',
);
// Staged: the drawn source stays until the new one is drawn.
await until(() => staged.ready(), 'the staged layer ready');
const first = outcome(
staged.setSource({ ...green, rectangle: box }, { staged: true }),
);
ensure(
staged.source().image === magenta.image ||
first.value === true ||
staged.source().image === green.image,
'a staged source replaced nothing',
);
await until(() => first.value !== undefined, 'the staged source');
ensure(first.value === true, 'the staged source failed');
ensure(
staged.source().image === green.image && !staged.staging(),
'the staged source is not the layer source',
);
// A newer source wins; an aborted one leaves the drawn source.
const superseded = outcome(
staged.setSource({ ...magenta, rectangle: box }, { staged: true }),
);
const winner = outcome(
staged.setSource(
{ kind: 'image', image: flat([0, 0, 255, 255]), rectangle: box },
{ staged: true },
),
);
await until(
() => superseded.value !== undefined && winner.value !== undefined,
'the superseded and the newer staged sources',
);
ensure(
superseded.value === false && winner.value === true,
`superseded ${superseded.value}, newer ${winner.value}`,
);
const drawnImage = staged.source().image;
const controller = new AbortController();
const aborted = outcome(
staged.setSource(
{ ...magenta, rectangle: box },
{ staged: true, signal: controller.signal },
),
);
controller.abort();
await until(() => aborted.value !== undefined, 'the aborted source');
ensure(
aborted.value === false && staged.source().image === drawnImage,
'an aborted staged source replaced the drawn one',
);
// A new credit alone keeps the source's pixels.
geographic.setSource({
...template,
scheme: 'geographic',
transform: geographic.source().transform,
credit: 'Contract credit',
});
ensure(
geographic.source().credit === 'Contract credit',
'the credit did not change',
);
// A hidden layer takes a staged source at once.
staged.show(false);
const hidden = outcome(
staged.setSource({ ...green, rectangle: box }, { staged: true }),
);
await until(() => hidden.value !== undefined, 'the hidden staged');
ensure(hidden.value === true, 'a hidden layer refused a stage');
// A raised layer's detail.
const west = { ...box, east: 19.5 };
await until(() => raisedLayer.ready(), 'the raised layer drawn');
const detail = outcome(
raisedLayer.setDetail({
image: flat([0, 255, 0, 255]),
rectangle: west,
}),
);
await until(() => detail.value !== undefined, 'the detail drawn');
ensure(
detail.value === true &&
raisedLayer.detail()?.rectangle.east === 19.5,
'the detail is not kept',
);
await raisedLayer.setDetail(null);
ensure(raisedLayer.detail() === null, 'the detail is not cleared');
} finally {
geographic.destroy();
staged.destroy();
raisedLayer.destroy();
}
ensure(!listed('contract-imagery-raised'), 'a raised layer is listed');
},
],
[
'imagery.createLayer: geographic tiles through a transform, staged and raised layers draw (pixels)',
async () => {
if (!readRegion) return;
await nadirView(1e6);
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
await settle(4);
// Every tile, whatever the app serves, painted magenta by the
// transform: geographic tiles over the box only.
const painted = (image) => {
const size = image.width;
const data = new Uint8Array(size * size * 4);
for (let i = 0; i < data.length; i += 4)
data.set([255, 0, 255, 255], i);
if (typeof document === 'undefined') return image;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const context = canvas.getContext('2d');
const pixels = context.createImageData(size, size);
pixels.data.set(data);
context.putImageData(pixels, 0, 0);
return canvas;
};
const layers = [];
try {
const tiles = imagery.createLayer({
id: 'contract-paint-geographic',
source: {
kind: 'tiles',
url: '/icons/icon-32.png?z={z}&x={x}&y={y}',
tileSize: 32,
maxLevel: 4,
scheme: 'geographic',
transform: painted,
rectangle: box,
},
});
layers.push(tiles);
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA),
`the transformed tiles drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
ensure(
!nearColour(meanAt(engine, readRegion, 21, 10, 5), MAGENTA),
'the tiles spill outside their rectangle',
);
// Staged: green replaces the magenta tiles once it is drawn.
const staged = tiles.setSource(
{ kind: 'image', image: flat([...GREEN, 255]), rectangle: box },
{ staged: true },
);
let swapped;
staged.then((value) => (swapped = value));
await until(() => swapped !== undefined, 'the staged image');
ensure(swapped === true, 'the staged image failed');
await until(
() => nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
`the staged image drawn (${show(meanAt(engine, readRegion, 19.5, 10, 5))})`,
);
tiles.destroy();
// Raised 6 km: drawn over the box (a little parallax at 1000 km),
// a detail in place of its west half.
const raisedLayer = imagery.createLayer({
id: 'contract-paint-raised',
source: {
kind: 'image',
image: flat([...MAGENTA, 255]),
rectangle: box,
},
raise: 6000,
});
layers.push(raisedLayer);
await until(
() => nearColour(meanAt(engine, readRegion, 19.6, 10, 5), MAGENTA),
`the raised layer drawn (${show(meanAt(engine, readRegion, 19.6, 10, 5))})`,
);
raisedLayer.setDetail({
image: flat([...GREEN, 255]),
rectangle: { ...box, east: 19.5 },
});
await until(
() =>
nearColour(meanAt(engine, readRegion, 19.25, 10, 5), GREEN) &&
nearColour(meanAt(engine, readRegion, 19.75, 10, 5), MAGENTA),
`the detail drawn (west ${show(meanAt(engine, readRegion, 19.25, 10, 5))}, east ${show(meanAt(engine, readRegion, 19.75, 10, 5))})`,
);
raisedLayer.setDetail(null);
await until(
() => nearColour(meanAt(engine, readRegion, 19.25, 10, 5), MAGENTA),
'the detail cleared',
);
} finally {
for (const layer of layers) layer.destroy();
}
await until(
() =>
!nearColour(meanAt(engine, readRegion, 19.5, 10, 5), MAGENTA) &&
!nearColour(meanAt(engine, readRegion, 19.5, 10, 5), GREEN),
'every layer gone',
);
},
],
];
}
export function imageryBehaviourChecks(
engine,
{ until, settle, nadirView, readRegion, resize, makeCanvas },
) {
const source = {
kind: 'image',
image: { width: 1, height: 1, data: new Uint8Array([51, 153, 102, 255]) },
rectangle: { west: 19, south: 9, east: 21, north: 11 },
};
const checks = [
[
'imagery.createLayer: an imagery pixel draws as asked within 10 per channel',
async () => {
await nadirView(10000);
const layer = engine.imagery.createLayer({
id: 'contract-imagery-colour',
source,
});
try {
await until(() => layer.ready(), 'imagery colour ready');
if (readRegion)
await until(
() =>
nearColour(
meanAt(engine, readRegion, 20, 10, 2),
[51, 153, 102],
10,
),
'imagery colour within 10 per channel',
);
} finally {
layer.destroy();
}
},
],
];
if (resize)
checks.push([
'imagery.createLayer: resize replans the visible tiles',
async () => {
const previous = engine.host.size();
let layer;
const drawn = () => {
const { width, height } = engine.host.size();
return [0.1, 0.9].every((fraction) => {
const pixel = readRegion(
Math.round(width * fraction),
Math.round(height / 2),
1,
1,
);
return nearColour(
Array.from(pixel).slice(0, 3),
[51, 153, 102],
10,
);
});
};
try {
await resize(400, 800);
await nadirView(1e6);
layer = engine.imagery.createLayer({
id: 'contract-imagery-resize',
source: makeCanvas
? {
kind: 'tiles',
url: '/icons/icon-32.png?contract={z}/{x}/{y}',
tileSize: 32,
maxLevel: 8,
transform: () => {
const canvas = makeCanvas(32, 32);
const context = canvas.getContext('2d');
context.fillStyle = '#339966';
context.fillRect(0, 0, 32, 32);
return canvas;
},
}
: source,
});
await until(() => layer.ready(), 'imagery before resize');
if (readRegion) await until(drawn, 'imagery drawn before resize');
await resize(1280, 800);
await until(() => {
const { width, height } = engine.host.size();
return width === 1280 && height === 800;
}, 'the resized canvas size');
await settle(4);
await until(() => layer.ready(), 'imagery after resize');
// A ready provider or transformed tile need not have drawn yet.
// The wider view must gain coverage on both sides, cached or new.
if (readRegion) await until(drawn, 'imagery drawn after resize');
} finally {
layer?.destroy();
await resize(previous.width, previous.height);
}
},
]);
return checks;
}
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../fakeEngine.js';
import { fakeContractOptions } from '../contractFixtures.js';
import { createContractSuite } from './index.mjs';
for (const scenario of ['delayed', 'cached', 'missing left', 'missing right']) {
test(`imagery resize check: ${scenario} tiles`, async () => {
const engine = createFakeEngine();
const previous = engine.host.size();
const options = fakeContractOptions(engine);
const createLayer = engine.imagery.createLayer.bind(engine.imagery);
let transform;
let resized = false;
let frames = 0;
engine.imagery.createLayer = (input) => {
transform = input.source.transform;
transform();
return createLayer(input);
};
const check = createContractSuite(engine, {
...options,
maxWaitFrames: 20,
makeCanvas: () => ({ getContext: () => ({ fillRect() {} }) }),
resize: async (width, height) => {
await options.resize(width, height);
resized = width === 1280 && height === 800;
frames = 0;
},
waitFrames: async (count) => {
await options.waitFrames(count);
if (resized) {
frames += count;
if (frames === 1 && scenario !== 'cached') transform();
}
},
readRegion: (x, y, width, height) => {
const size = engine.host.size();
assert.ok(x >= 0 && x < size.width);
assert.equal(y, size.height / 2);
assert.equal(width, 1);
assert.equal(height, 1);
let painted = true;
if (resized) {
if (scenario === 'delayed') painted = frames >= 12;
if (scenario === 'missing left') painted = x > size.width / 2;
if (scenario === 'missing right') painted = x < size.width / 2;
}
return new Uint8Array(
painted ? [51, 153, 102, 255] : [242, 239, 233, 255],
);
},
}).find(({ name }) => name.startsWith('imagery.createLayer: resize'));
try {
if (scenario.startsWith('missing'))
await assert.rejects(
check.run(),
/imagery drawn after resize did not happen within 20 frames/,
);
else await check.run();
assert.deepEqual(engine.host.size(), previous);
assert.deepEqual(engine.imagery.list(), []);
} finally {
engine.destroy();
}
});
}
+217
View File
@@ -0,0 +1,217 @@
// Behavioural checks every GlobeEngine must pass. The suite is engine-agnostic
// and imports nothing from Node, so it runs in Node against the fake engine and
// in a headless browser against the Cesium and Navara engines:
//
// const checks = createContractSuite(engine, { waitFrames });
// for (const { name, run } of checks) await run();
//
// `waitFrames(count)` resolves after the engine has rendered `count` frames.
// Checks request renders themselves, so engines in request-render mode work.
// Each check sets the view it needs; checks may leave the camera anywhere.
import { engineCapabilities } from '../../contract.js';
import {
mapErrorChecks,
terrainRevisionChecks,
photorealChecks,
terrainChecks,
mapChecks,
} from './maps.mjs';
import {
moverListChecks,
failedModelChecks,
spriteChecks,
moverChecks,
} from './movers.mjs';
import { groundBehaviourChecks, groundChecks } from './ground.mjs';
import { imageryBehaviourChecks, imageryChecks } from './imagery.mjs';
import { clockLatchChecks, clockChecks } from './clock.mjs';
import {
terrainSampleChecks,
surfaceChecks,
globePickChecks,
} from './surface.mjs';
import {
renderBehaviourChecks,
renderChecks,
contactSonarChecks,
cloudCoverChecks,
bloomChecks,
styleChecks,
} from './render.mjs';
import {
cameraBehaviourChecks,
poseChecks,
flightChecks,
swingChecks,
cameraChecks,
aspectChecks,
followChecks,
} from './camera.mjs';
import { cartesian2, cartesian3, math, ensure } from './helpers.mjs';
import {
shapeChecks,
eventChecks,
creditChecks,
listenerChecks,
lifetimeChecks,
} from './events.mjs';
import { lineChecks, dashChecks } from './lines.mjs';
import { pickInputChecks, pointerChecks, inputChecks } from './input.mjs';
import { meshChecks } from './meshes.mjs';
/**
* @param {import('../../contract.js').GlobeEngine} engine
* @param {{ waitFrames: (count: number) => Promise<void>, maxWaitFrames?: number,
* mapSources?: { imagery: object[], photoreal: object },
* readRegion?: (x: number, y: number, width: number, height: number) => Uint8ClampedArray,
* makeCanvas?: (width: number, height: number) => object,
* resize?: (width: number, height: number) => Promise<void>,
* fixtures?: object,
* pointer?: { click: (x: number, y: number) => Promise<void>, move: (x: number, y: number) => Promise<void>,
* drag?: (x: number, y: number, endX: number, endY: number) => Promise<void> } }} options
* `mapSources` (two imagery sources and a photoreal one the engine can
* show) adds the maps checks; they leave photoreal tiles hidden.
* `readRegion` (RGBA bytes of the last drawn frame in a CSS-pixel box) and
* `makeCanvas` (a 2D canvas) add the pixel checks of drawn objects.
* `pointer` (a real left click and pointer move at a canvas position)
* adds the pick checks; `drag` adds the flight gesture check.
* `fixtures` supplies deterministic provider inputs; `resize` exercises
* imagery replanning. waitFrames must observe frames without requesting
* them, and reject if no requested frame arrives.
* @returns {{ name: string, run: () => Promise<void> }[]}
*/
export function createContractSuite(
engine,
{
waitFrames,
maxWaitFrames = 240,
mapSources = null,
readRegion = null,
makeCanvas = null,
pointer = null,
fixtures = null,
resize = null,
},
) {
if (typeof waitFrames !== 'function')
throw new TypeError('createContractSuite needs a waitFrames(count) helper');
const { camera } = engine;
const settle = async (count = 2) => {
for (let index = 0; index < count; index++) {
engine.render.request();
await waitFrames(1);
}
};
const until = async (predicate, label) => {
for (let frame = 0; frame < maxWaitFrames; frame++) {
if (predicate()) return;
await settle(1);
}
ensure(
predicate(),
`${label} did not happen within ${maxWaitFrames} frames`,
);
};
// A view 1000 km above (20°E, 10°N) looking straight down, heading north.
const nadirView = async (height = 1e6) => {
camera.setView({
destination: cartesian3.fromDegrees(20, 10, height),
orientation: { heading: 0, pitch: -math.PI_OVER_TWO, roll: 0 },
});
await settle();
};
const centre = () => {
const { width, height } = engine.host.size();
return cartesian2.create(width / 2, height / 2);
};
const capabilities = engineCapabilities(engine);
const context = {
settle,
until,
nadirView,
centre,
readRegion,
makeCanvas,
pointer,
waitFrames,
fixtures,
resize,
mapSources,
};
const checks = [
...shapeChecks(engine, context),
...poseChecks(engine, context),
...eventChecks(engine, context),
...clockChecks(engine, context),
...spriteChecks(engine, context),
...lineChecks(engine, context),
...pickInputChecks(engine, context),
...dashChecks(engine, context),
...moverChecks(engine, context),
...renderChecks(engine, context),
...flightChecks(engine, context),
...surfaceChecks(engine, context),
...pointerChecks(engine, context),
...swingChecks(engine, context),
...inputChecks(engine, context),
...creditChecks(engine, context),
...photorealChecks(engine, context),
...listenerChecks(engine, context),
...cameraChecks(engine, context),
...lifetimeChecks(engine, context),
...aspectChecks(engine, context),
...globePickChecks(engine, context),
...terrainChecks(engine, context),
...followChecks(engine, context),
];
checks.push(
...meshChecks(engine, { settle, until, nadirView, readRegion, makeCanvas }),
...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }),
...imageryChecks(engine, { settle, until, nadirView, readRegion }),
);
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
checks.push(...mapChecks(engine.maps, mapSources));
if (capabilities.visualStyles)
checks.push(...styleChecks(engine.render, settle));
if (capabilities.bloom) checks.push(...bloomChecks(engine.render, settle));
if (capabilities.cloudCover)
checks.push(...cloudCoverChecks(engine.render, settle));
if (capabilities.contactSonar)
checks.push(...contactSonarChecks(engine.render, settle));
checks.push(
...cameraBehaviourChecks(engine, context),
...renderBehaviourChecks(engine, context),
...terrainSampleChecks(engine, context),
...terrainRevisionChecks(engine, context),
...clockLatchChecks(engine, context),
...groundBehaviourChecks(engine, context),
...imageryBehaviourChecks(engine, context),
...mapErrorChecks(engine, context),
...moverListChecks(engine),
);
if (fixtures?.failModel || makeCanvas)
checks.push(...failedModelChecks(engine, context));
return checks.map(([name, run]) => ({ name, run }));
}
/** Runs every check in order; never throws for a failing check. */
export async function runContractSuite(engine, options) {
const results = [];
for (const { name, run } of createContractSuite(engine, options)) {
try {
await run();
results.push({ name, ok: true });
} catch (error) {
results.push({
name,
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
return results;
}
+260
View File
@@ -0,0 +1,260 @@
import { cartesian3, ensure } from './helpers.mjs';
import { INPUT_TYPES, MODIFIERS } from '../../contract.js';
export function pickInputChecks(
engine,
{ settle, until, nadirView, makeCanvas, pointer },
) {
const { camera } = engine;
return [
[
'input.on: picks on click and move payloads, drill, lines',
async () => {
await nadirView(1e6);
const at = (lon, lat, height = 20_000) =>
cartesian3.fromDegrees(lon, lat, height);
const atlasCanvas = makeCanvas
? makeCanvas(16, 16)
: { width: 16, height: 16 };
if (makeCanvas) {
const context = atlasCanvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 16, 16);
}
const set = engine.movers.createSet({
id: 'contract-picks',
kind: 'sprite',
capacity: 3,
order: 100,
depthTest: 'always',
pickable: true,
sprite: {
atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } },
sizePx: 24,
},
});
const quiet = engine.movers.createSet({
id: 'contract-quiet',
kind: 'sprite',
capacity: 1,
order: 100,
depthTest: 'always',
sprite: {
atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } },
sizePx: 24,
},
});
const line = engine.lines.createLine({
id: 'contract-pick-line',
widthPx: 8,
color: [1, 1, 0, 1],
pickId: 'line-1',
});
const clicks = [];
const moves = [];
const removers = [
engine.input.on('click', (event) => clicks.push(event), {
pick: true,
drill: 8,
pickWindow: { width: 9, height: 9 },
}),
engine.input.on('move', (event) => moves.push(event), {
pick: true,
}),
];
try {
const a = at(20, 10);
const b = at(21, 10.5);
set.set(0, { position: a, pickId: 'a', color: [1, 0, 1, 1] });
set.set(1, { position: b, pickId: 'b', color: [1, 0, 1, 1] });
set.set(2, { position: b, pickId: 'c', color: [1, 0, 1, 1] });
set.setCount(3);
set.commit();
line.setPositions([at(19, 9), at(21, 9)]);
const unpickable = at(19.5, 11);
quiet.set(0, { position: unpickable, pickId: 'quiet' });
quiet.setCount(1);
quiet.commit();
await settle(4);
if (!pointer) return;
const screen = (position) => engine.projector.worldToWindow(position);
const clickAt = async (position) => {
const before = clicks.length;
const { x, y } = screen(position);
await pointer.click(x, y);
await until(() => clicks.length > before, 'a click payload');
return clicks[clicks.length - 1];
};
const onA = await clickAt(a);
ensure(
onA.picked?.id === 'a' && onA.picked?.owner === 'contract-picks',
`a click on sprite a picked ${JSON.stringify(onA.picked?.id)} of ${onA.picked?.owner}`,
);
ensure(onA.picked.instance === 0, `instance ${onA.picked.instance}`);
const empty = await clickAt(at(18, 12));
ensure(
empty.picked === undefined || empty.picked.id === undefined,
`a click on the map picked ${JSON.stringify(empty.picked?.id)}`,
);
const onB = await clickAt(b);
const drilled = (onB.drilled ?? []).map(({ id }) => id);
ensure(
drilled.includes('b') && drilled.includes('c'),
`two sprites in one place drilled ${JSON.stringify(drilled)}`,
);
const onQuiet = await clickAt(unpickable);
ensure(
onQuiet.picked?.owner !== 'contract-quiet' &&
!(onQuiet.drilled ?? []).some(
({ owner }) => owner === 'contract-quiet',
),
`a pickable: false sprite was picked (${JSON.stringify(onQuiet.picked?.id)})`,
);
const linePosition = at(20, 9);
const lineCameraBefore = cartesian3.clone(
engine.camera.pose().position,
);
const onLine = await clickAt(linePosition);
ensure(
onLine.picked?.id === 'line-1',
`a click on the line picked ${JSON.stringify(onLine.picked?.id)}; ${
onLine.picked?.id === 'line-1'
? ''
: JSON.stringify({
event: onLine.position,
projectedNow: screen(linePosition),
directNow: engine.picker.pick(onLine.position)?.id,
drilled: (onLine.drilled ?? []).map(({ id }) => id),
cameraMovedM: cartesian3.distance(
lineCameraBefore,
engine.camera.pose().position,
),
})
}`,
);
const target = screen(a);
const movesBefore = moves.length;
await pointer.move(target.x + 1, target.y);
await until(
() => moves.length > movesBefore && moves.at(-1).picked?.id === 'a',
'a hover payload over sprite a',
);
} finally {
for (const remove of removers) remove();
set.destroy();
quiet.destroy();
line.destroy();
}
},
],
];
}
export function pointerChecks(engine, { settle, pointer }) {
const { camera } = engine;
return [
[
'input.acquirePointer: exclusive subscriptions, release and camera gestures',
async () => {
const input = engine.input;
const before = input.cameraInputsEnabled();
const claim = input.acquirePointer();
ensure(claim, 'no pointer claim');
const removers = [];
let ambient = 0,
owned = 0;
try {
ensure(input.acquirePointer() === null, 'a second claim succeeded');
ensure(
input.cameraInputsEnabled() === before,
'claim disabled camera gestures',
);
removers.push(input.on('click', () => ambient++));
removers.push(input.on('click', () => owned++, { pointer: claim }));
if (pointer) {
await pointer.click(100, 100);
await settle();
ensure(owned === 1 && ambient === 0, 'exclusive click leaked');
}
claim.release();
claim.release();
if (pointer) {
await pointer.click(100, 100);
await settle();
ensure(
owned === 1 && ambient === 1,
'released click stayed exclusive',
);
}
const next = input.acquirePointer();
ensure(next, 'release did not free the pointer');
claim.release();
ensure(
input.acquirePointer() === null,
'stale release freed its successor',
);
next.release();
} finally {
claim.release();
for (const remove of removers) remove();
}
},
],
];
}
export function inputChecks(engine) {
const { camera } = engine;
return [
[
'input.on: every type subscribes and unsubscribes; camera inputs toggle',
async () => {
for (const type of INPUT_TYPES) {
for (const modifier of [undefined, ...MODIFIERS]) {
const off = engine.input.on(type, () => {}, { modifier });
ensure(
typeof off === 'function',
`input.on('${type}', ${modifier}) returned no remover`,
);
off();
off();
}
}
},
],
[
'input.suspendCameraInputs: counted, the last resume restores the state before the first',
async () => {
const { input } = engine;
const before = input.cameraInputsEnabled();
ensure(
typeof before === 'boolean',
`cameraInputsEnabled() returned ${before}`,
);
const outer = input.suspendCameraInputs();
const inner = input.suspendCameraInputs();
try {
ensure(
typeof outer === 'function' && typeof inner === 'function',
'suspendCameraInputs returned no resume',
);
ensure(!input.cameraInputsEnabled(), 'on under a suspension');
inner();
inner();
ensure(
!input.cameraInputsEnabled(),
'an inner resume lifted the outer suspension',
);
outer();
ensure(
input.cameraInputsEnabled() === before,
'last resume did not restore inputs',
);
} finally {
inner();
outer();
}
},
],
];
}
+299
View File
@@ -0,0 +1,299 @@
import { cartesian3, math, show, ensure, near, along } from './helpers.mjs';
export function lineChecks(
engine,
{ settle, until, nadirView, centre, readRegion, pointer },
) {
return [
[
'lines.createLine: a line draws between its projections, turns with its model matrix and dims where hidden',
async () => {
await nadirView(1e6);
// 20 km up: above any ground the scene draws.
const at = (lon, lat, height = 20_000) =>
cartesian3.fromDegrees(lon, lat, height);
const line = engine.lines.createLine({
id: 'contract-line',
widthPx: 8,
color: [1, 0, 1, 1],
});
const hidden = engine.lines.createLine({
id: 'contract-line-hidden',
widthPx: 10,
color: [1, 0, 1, 1],
depthFailAlpha: 0.6,
});
try {
line.setPositions([at(19, 9), at(20, 9), at(21, 9)]);
ensure(
engine.lines
.list()
.some(({ id, points }) => id === 'contract-line' && points === 3),
'lines.list() does not show the line',
);
if (!readRegion) return;
// Magenta-leaning pixels (red and blue over green; a dimmed copy
// over a light map stays above 30) near a point.
// The strongest magenta lean near a point (for dimmer-than).
const strength = (position) => {
const screen = engine.projector.worldToWindow(position);
const data = readRegion(
Math.round(screen.x) - 6,
Math.round(screen.y) - 6,
12,
12,
);
let best = -255;
for (let i = 0; i < data.length; i += 4)
best = Math.max(
best,
Math.min(data[i], data[i + 2]) - data[i + 1],
);
return best;
};
const magentaAt = (position) => {
const screen = engine.projector.worldToWindow(position);
if (!screen) return 0;
const data = readRegion(
Math.round(screen.x) - 6,
Math.round(screen.y) - 6,
12,
12,
);
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (
Math.min(data[i], data[i + 2]) - data[i + 1] > 30 &&
data[i] > 120
)
count++;
return count;
};
await until(
() => magentaAt(at(19.5, 9)) > 10 && magentaAt(at(20.5, 9)) > 10,
'the line drawn along both segments',
);
ensure(magentaAt(at(19.5, 10.5)) === 0, 'line pixels off the line');
// Turned 1° about the Earth's axis: the line moves 1° east.
const turn = (radians) => {
const c = Math.cos(radians);
const s = Math.sin(radians);
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
};
line.setModelMatrix(turn(math.toRadians(1)));
await until(
() => magentaAt(at(21.5, 9)) > 10 && magentaAt(at(19.25, 9)) === 0,
'the turned line drawn 1° east',
);
const shownStrength = strength(at(20.5, 9));
line.show(false);
await until(() => magentaAt(at(20.5, 9)) === 0, 'the line hidden');
// Round the back of the Earth along the great circle west of the
// view centre, out to a visible end past the western horizon. The
// far side, behind the Earth's centre, shows only by its dimmed
// depth-fail copy. Cesium culls a line the globe hides whole, and
// draws in full what lies under the horizon but before the centre.
const west = (degrees) => {
const t = (degrees * Math.PI) / 180;
const [lon0, lat0] = [(20 * Math.PI) / 180, (10 * Math.PI) / 180];
const [x, y, z] = [
Math.cos(lat0) * Math.cos(lon0) * Math.cos(t) +
Math.sin(lon0) * Math.sin(t),
Math.cos(lat0) * Math.sin(lon0) * Math.cos(t) -
Math.cos(lon0) * Math.sin(t),
Math.sin(lat0) * Math.cos(t),
];
return at(
(Math.atan2(y, x) * 180) / Math.PI,
(Math.asin(z) * 180) / Math.PI,
);
};
const deep = () => west(150);
hidden.setPositions([200, 150, 100, 50, 20].map(west));
await until(
() => magentaAt(deep()) > 10,
'the hidden line drawn by its depth-fail copy',
);
const hiddenStrength = strength(deep());
ensure(
hiddenStrength < shownStrength,
`the depth-fail copy (${hiddenStrength}) is not dimmer than the line (${shownStrength})`,
);
hidden.destroy();
await until(() => magentaAt(deep()) === 0, 'the destroyed line gone');
} finally {
line.destroy();
hidden.destroy();
}
ensure(
!engine.lines.list().some(({ id }) => id.startsWith('contract-line')),
'a destroyed line is still listed',
);
},
],
[
'lines.createLine: batched segments stay disconnected and retain individual picks',
async () => {
await nadirView(1e6);
const at = (lon, lat) => cartesian3.fromDegrees(lon, lat, 20_000);
const id = 'contract-segments';
const batch = engine.lines.createSegments({
id,
widthPx: 10,
color: [1, 0, 1, 1],
});
const screen = (lon, lat) =>
engine.projector.worldToWindow(at(lon, lat));
const pick = (lon, lat) => engine.picker.pick(screen(lon, lat));
const magenta = (lon, lat) => {
const p = screen(lon, lat);
const pixels = readRegion(
Math.round(p.x) - 5,
Math.round(p.y) - 5,
10,
10,
);
let count = 0;
for (let i = 0; i < pixels.length; i += 4)
if (Math.min(pixels[i], pixels[i + 2]) - pixels[i + 1] > 30)
count++;
return count;
};
try {
batch.replace([
{ positions: [at(19, 9.5), at(19, 10.5)], pickId: 'west' },
{
positions: [at(21, 9.5), at(21, 10.5)],
widthPx: 12,
pickId: 'east',
},
{ positions: [at(20, 10.8), at(20, 11.2)] },
]);
batch.commit();
ensure(
engine.lines.list().find((entry) => entry.id === id)?.points === 6,
'batch endpoints',
);
if (!pointer && !readRegion) return;
await until(
() => pick(19, 10)?.id === 'west' && pick(21, 10)?.id === 'east',
'both segment picks',
);
ensure(pick(19, 10)?.owner === id, 'batch pick owner');
ensure(
pick(20, 11)?.owner !== id,
'segment without a pick id is ignored',
);
ensure(pick(20, 10)?.owner !== id, 'no connecting segment pick');
if (readRegion) {
await until(
() => magenta(19, 10) > 10 && magenta(21, 10) > 10,
'both segments drawn',
);
ensure(magenta(20, 10) === 0, 'no connecting segment pixels');
}
batch.show(false);
await settle(3);
ensure(pick(19, 10)?.owner !== id, 'hidden batch is not picked');
batch.replace([
{ positions: [at(21, 9.5), at(21, 10.5)], pickId: 'replacement' },
]);
batch.commit();
await settle(3);
ensure(pick(21, 10)?.owner !== id, 'replacement stays hidden');
batch.show(true);
await until(
() => pick(21, 10)?.id === 'replacement',
'replacement pick',
);
ensure(pick(19, 10)?.owner !== id, 'removed segment is not picked');
batch.replace([]);
batch.commit();
await settle(3);
ensure(pick(21, 10)?.owner !== id, 'empty batch is not picked');
} finally {
batch.destroy();
batch.destroy();
}
ensure(
!engine.lines.list().some((entry) => entry.id === id),
'batch released',
);
},
],
];
}
export function dashChecks(engine, { until, nadirView, readRegion }) {
return [
[
'lines.createLine: pixel dashes are regular, in CSS pixels',
async () => {
if (!readRegion) return;
await nadirView(1e6);
const line = engine.lines.createLine({
id: 'contract-dashes',
widthPx: 6,
color: [1, 0, 1, 1],
dash: { lengthPx: 16, gapPx: 16 },
});
try {
const a = cartesian3.fromDegrees(19, 9, 20_000);
const b = cartesian3.fromDegrees(21, 9, 20_000);
line.setPositions([a, b]);
const sa = engine.projector.worldToWindow(a);
const sb = engine.projector.worldToWindow(b);
// On/off runs (CSS px) along the middle of the line.
const runs = () => {
const left = Math.round(Math.min(sa.x, sb.x)) + 4;
const width = Math.round(Math.abs(sb.x - sa.x)) - 8;
const y = Math.round((sa.y + sb.y) / 2);
const data = readRegion(left, y, width, 1);
const on = [];
const off = [];
let current = null;
let length = 0;
for (let x = 0; x < width; x++) {
const i = x * 4;
const lit =
Math.min(data[i], data[i + 2]) - data[i + 1] > 60 &&
data[i] > 150;
if (lit === current) length++;
else {
if (current !== null) (current ? on : off).push(length);
current = lit;
length = 1;
}
}
// The first and last runs are cut by the window.
return { on: on.slice(1), off: off.slice(1) };
};
const median = (values) =>
[...values].sort((x, y) => x - y)[Math.floor(values.length / 2)];
await until(() => runs().on.length >= 3, 'dashes drawn');
const { on, off } = runs();
const spread = (values) => Math.max(...values) - Math.min(...values);
// What each engine draws for 16/16, for the harness to compare
// across device pixel ratios (Cesium's PolylineDash is the
// reference; Navara's must not change with the ratio).
globalThis.__qaDashes = {
dash: median(on),
gap: median(off),
period: median(on) + median(off),
};
ensure(
on.length >= 3 && spread(on) <= 2 && spread(off) <= 2,
`irregular dashes: runs ${JSON.stringify(on)} / ${JSON.stringify(off)}`,
);
if (engine.kind === 'navara')
ensure(
Math.abs(median(on) - 16) <= 2 && Math.abs(median(off) - 16) <= 2,
`dash ${median(on)} px, gap ${median(off)} px (16/16 asked)`,
);
} finally {
line.destroy();
}
},
],
];
}
+325
View File
@@ -0,0 +1,325 @@
import { createEngineEvent, engineCapabilities } from '../../contract.js';
import { MapSourceController } from '../../../maps/controller.js';
import { ensure } from './helpers.mjs';
export function photorealChecks(engine) {
return [
[
'maps.setPhotorealTiles: photoreal is inactive',
async () => {
engine.maps.setPhotorealTiles(null);
ensure(
engine.maps.photorealActive() === false,
'photoreal still active',
);
},
],
];
}
export function terrainChecks(engine) {
return [
[
'maps.setTerrain: keyless terrain resolves; an unknown kind rejects',
async () => {
await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' });
let rejected = null;
try {
await engine.maps.setTerrain({
id: 'no-such-terrain',
kind: 'no-such-terrain',
});
} catch (error) {
rejected = error;
}
ensure(rejected !== null, 'an unknown terrain kind resolved');
// The keyless terrain still applies after the rejection.
await engine.maps.setTerrain({ id: 'keyless', kind: 'keyless' });
},
],
];
}
export function mapChecks(maps, { imagery: [first, second], photoreal }) {
const shows = (baseImagery, tiles, label) => {
const current = maps.current();
ensure(
current.baseImagery === baseImagery,
`${label}: base imagery is not the expected source`,
);
ensure(
current.photoreal === tiles,
`${label}: photoreal tiles are not the expected source`,
);
ensure(
maps.photorealActive() === (tiles !== null),
`${label}: photorealActive is ${maps.photorealActive()}`,
);
};
return [
[
'maps.setBaseImagery: setPhotorealTiles(null) brings back the last base imagery',
async () => {
await maps.setBaseImagery(first);
shows(first, null, 'after setBaseImagery');
await maps.setPhotorealTiles(photoreal);
shows(null, photoreal, 'tiles replace the base imagery');
await maps.setPhotorealTiles(null);
shows(first, null, 'null');
},
],
[
'maps.setBaseImagery: the last imagery or photoreal call decides what shows',
async () => {
await maps.setBaseImagery(first);
await Promise.all([
maps.setBaseImagery(second),
maps.setPhotorealTiles(photoreal),
]);
shows(null, photoreal, 'tiles last');
await maps.setPhotorealTiles(null);
const restored = maps.current().baseImagery;
// The second source was superseded before or after it showed.
ensure(
restored === first || restored === second,
'null: the last base imagery shown is not back',
);
await Promise.all([
maps.setPhotorealTiles(photoreal),
maps.setBaseImagery(second),
]);
ensure(
maps.current().baseImagery === second,
'imagery last: the second source is not on the globe',
);
await maps.setPhotorealTiles(null);
shows(second, null, 'null after imagery');
},
],
[
'maps.releasePhotorealTiles: a released source no longer shows or counts',
async () => {
await maps.setPhotorealTiles(photoreal);
await maps.setPhotorealTiles(null);
maps.releasePhotorealTiles(photoreal);
maps.releasePhotorealTiles(photoreal);
ensure(!maps.photorealActive(), 'photorealActive after release');
ensure(maps.current().photoreal === null, 'current after release');
},
],
];
}
export function terrainRevisionChecks(engine, { fixtures }) {
return [
[
'maps.terrainRevision: changes when setTerrain replaces the surface',
async () => {
await engine.maps.setTerrain({ kind: 'ellipsoid' });
const before = engine.maps.terrainRevision();
await engine.maps.setTerrain({ kind: 'keyless' });
ensure(
engine.maps.terrainRevision() > before,
'setTerrain kept the terrain revision',
);
},
],
[
'maps.terrainRevision: changes when falling back to the ellipsoid',
async () => {
const maps = fixtures?.maps ?? engine.maps;
await maps.setTerrain(fixtures?.terrainSource ?? { kind: 'keyless' });
const before = maps.terrainRevision();
try {
if (fixtures?.fallbackTerrain) await fixtures.fallbackTerrain();
else await maps.setTerrain({ kind: 'ellipsoid' });
ensure(
maps.terrainRevision() > before,
'fallback kept the terrain revision',
);
} finally {
await maps.setTerrain(fixtures?.terrainSource ?? { kind: 'keyless' });
}
},
],
];
}
export function mapErrorChecks(engine, { fixtures }) {
if (!fixtures) return [];
const maps = fixtures.maps ?? engine.maps;
const supported = engineCapabilities({ maps }).tileErrors;
const checks = [
[
'maps.errors: tileErrors describes provider error support',
async () => {
ensure(
supported === engineCapabilities(engine).tileErrors,
'fixture capability differs from the engine',
);
if (supported)
ensure(
typeof maps.errors.addEventListener === 'function',
'missing error event',
);
else
ensure(
maps.errors === undefined,
'unsupported tile errors were advertised',
);
},
],
];
if (supported)
checks.push([
'maps.errors: forwards source, kind and status and drops replaced providers',
async () => {
const first = {
kind: 'osm',
provider: { errorEvent: createEngineEvent() },
};
const second = {
kind: 'osm',
provider: { errorEvent: createEngineEvent() },
};
const seen = [];
const remove = maps.errors.addEventListener((error) =>
seen.push(error),
);
try {
await maps.setBaseImagery(first);
first.provider.errorEvent.raiseEvent({ error: { statusCode: 503 } });
ensure(
seen.length === 1 &&
seen[0].source === first &&
seen[0].kind === 'tile' &&
seen[0].status === 503,
'tile error payload',
);
await maps.setBaseImagery(second);
first.provider.errorEvent.raiseEvent({ statusCode: 401 });
ensure(seen.length === 1, 'replaced imagery reported an error');
second.provider.errorEvent.raiseEvent({ statusCode: 429 });
ensure(
seen.length === 2 &&
seen[1].source === second &&
seen[1].status === 429,
'replacement errors not observed',
);
} finally {
remove();
}
},
]);
if (supported)
checks.push([
'maps.errors: terrain failures follow the current provider',
async () => {
const first = {
kind: 'keyless',
provider: { errorEvent: createEngineEvent() },
};
const second = {
kind: 'ellipsoid',
provider: { errorEvent: createEngineEvent() },
};
first.create = () => ({ provider: first.provider });
second.create = () => ({ provider: second.provider, fallback: true });
const seen = [];
const remove = maps.errors.addEventListener((error) =>
seen.push(error),
);
try {
await maps.setTerrain(first);
first.provider.errorEvent.raiseEvent({ error: { statusCode: 503 } });
ensure(
seen.length === 1 &&
seen[0].source === first &&
seen[0].kind === 'terrain' &&
seen[0].status === 503,
'terrain error payload',
);
await maps.setTerrain(second);
first.provider.errorEvent.raiseEvent({ statusCode: 401 });
ensure(seen.length === 1, 'replaced terrain reported an error');
second.provider.errorEvent.raiseEvent({});
ensure(
seen.length === 2 &&
seen[1].source === second &&
seen[1].kind === 'terrain' &&
seen[1].status === undefined,
'unknown terrain status',
);
} finally {
remove();
}
},
]);
for (const enabled of supported ? [true, false] : [false])
checks.push([
`maps.errors: tile failure fallback ${enabled ? 'runs with' : 'stays off without'} tileErrors`,
async () => {
const providers = [createEngineEvent(), createEngineEvent()];
const sources = ['contract-primary', 'contract-fallback'].map(
(id, i) => ({
descriptor: { id, label: id, kind: 'osm' },
imagery: async () => ({ errorEvent: providers[i] }),
...(i === 0 && {
tileFailureFallback: {
id: 'contract-fallback',
threshold: 2,
message: 'Map tiles unavailable',
},
}),
}),
);
const events = [];
const controlled = {
...engine,
maps: enabled ? maps : { ...maps, errors: undefined },
};
const controller = new MapSourceController({
engine: controlled,
registry: { defaultId: 'contract-primary', sources },
initialStack: 'contract-primary',
onChange: (state) => events.push(state),
});
try {
await controller.setStack('contract-primary');
const source = maps.current().baseImagery;
if (enabled) {
maps.errors.raiseEvent({ source, kind: 'terrain', status: 503 });
maps.errors.raiseEvent({ source: {}, kind: 'tile', status: 503 });
ensure(
controller.getActiveId() === 'contract-primary',
'unrelated errors switched the map',
);
maps.errors.raiseEvent({ source, kind: 'tile', status: 503 });
ensure(
controller.getActiveId() === 'contract-primary',
'one tile error switched the map',
);
maps.errors.raiseEvent({ source, kind: 'tile', status: 503 });
} else {
providers[0].raiseEvent({ statusCode: 503 });
providers[0].raiseEvent({ statusCode: 503 });
}
for (let i = 0; i < 60; i++) await Promise.resolve();
ensure(
controller.getActiveId() ===
(enabled ? 'contract-fallback' : 'contract-primary'),
'tile failure fallback ignored the capability',
);
if (!enabled)
ensure(
controller.getState().lastError === null &&
events.at(-1)?.status === 'ready',
'unsupported errors prevented map readiness',
);
} finally {
controller.destroy();
}
},
]);
return checks;
}
+167
View File
@@ -0,0 +1,167 @@
import { cartesian3, show, ensure } from './helpers.mjs';
export function meshChecks(
engine,
{ settle, until, nadirView, readRegion, makeCanvas },
) {
// Above any terrain under (20°E, 10°N), which stands about 400 m high.
const positions = [
[19.98, 10.02, 3000],
[20.02, 10.02, 3000],
[19.98, 9.98, 3000],
[20.02, 9.98, 3000],
].flatMap((p) => {
const at = cartesian3.fromDegrees(...p);
return [at.x, at.y, at.z];
});
const options = {
positions,
indices: [0, 2, 1, 2, 3, 1],
uv: [0, 1, 1, 1, 0, 0, 1, 0],
};
const centre = () => {
const { width, height } = engine.host.size();
return { x: width / 2, y: height / 2 };
};
const checks = [
[
'meshes.createMesh: copied triangles, visibility, updates and lifetime',
async () => {
const id = 'contract-mesh';
const source = [...positions];
const mesh = engine.meshes.createMesh({
...options,
positions: source,
id,
show: false,
});
try {
source.fill(0);
ensure(engine.meshes.list().includes(id), 'mesh is not listed');
ensure(mesh.show() === false, 'mesh starts hidden');
mesh.show(true);
ensure(mesh.show(), 'mesh did not show');
mesh.update({ color: [0, 1, 1, 0.35], depthTest: 'always' });
mesh.update({
positions,
indices: options.indices,
depthTest: 'normal',
});
await settle();
let rejected = false;
try {
mesh.update({ indices: [0, 1, 99] });
} catch {
rejected = true;
}
ensure(rejected, 'invalid indices were accepted');
ensure(mesh.show(), 'invalid update changed visibility');
} finally {
mesh.destroy();
mesh.destroy();
}
ensure(!engine.meshes.list().includes(id), 'destroyed mesh is listed');
ensure(!mesh.show(), 'destroyed mesh is shown');
},
],
[
'meshes.createMesh: picks use the live id and ignore hidden or unpickable triangles',
async () => {
await nadirView(10000);
const id = 'contract-mesh-pick';
const mesh = engine.meshes.createMesh({
...options,
id,
pickId: 'first',
});
try {
await until(
() => engine.picker.pick(centre())?.id === 'first',
'mesh pick',
);
ensure(engine.picker.pick(centre()).owner === id, 'mesh owner');
mesh.update({ pickId: 'second' });
await until(
() => engine.picker.pick(centre())?.id === 'second',
'updated mesh pick',
);
mesh.show(false);
await settle();
ensure(
engine.picker.pick(centre())?.owner !== id,
'hidden mesh picked',
);
mesh.update({ show: true, pickId: null });
await settle();
ensure(
engine.picker.pick(centre())?.owner !== id,
'unpickable mesh picked',
);
} finally {
mesh.destroy();
}
},
],
];
if (readRegion && makeCanvas)
checks.push([
'meshes.createMesh: image pixels update on the same canvas',
async () => {
await nadirView(10000);
const canvas = makeCanvas(8, 8);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ff0000';
ctx.fillRect(0, 0, 8, 8);
const mesh = engine.meshes.createMesh({
...options,
id: 'contract-mesh-image',
image: canvas,
});
const pixel = () => {
const { x, y } = centre();
return readRegion(Math.floor(x), Math.floor(y), 1, 1);
};
try {
await until(() => {
const p = pixel();
return p[0] > p[1] + 50;
}, 'red mesh image');
ctx.fillStyle = '#00ff00';
ctx.fillRect(0, 0, 8, 8);
mesh.update({ image: canvas });
await until(() => {
const p = pixel();
return p[1] > p[0] + 50;
}, 'updated green mesh image');
} finally {
mesh.destroy();
}
},
]);
checks.push([
'meshes.createMesh: a single colour draws as asked within 10 per channel',
async () => {
await nadirView(10000);
const colour = [0, 1, 0, 1];
const mesh = engine.meshes.createMesh({
...options,
id: 'contract-mesh-colour',
color: colour,
});
try {
ensure(mesh.id === 'contract-mesh-colour', 'mesh id');
if (!readRegion) return;
await until(() => {
const { x, y } = centre();
const pixel = readRegion(Math.round(x), Math.round(y), 1, 1);
return colour
.slice(0, 3)
.every((value, i) => Math.abs(pixel[i] - value * 255) <= 10);
}, 'mesh colour within 10 per channel');
} finally {
mesh.destroy();
}
},
]);
return checks;
}
+584
View File
@@ -0,0 +1,584 @@
import {
cartesian3,
like,
MAGENTA,
show,
ensure,
near,
unit,
} from './helpers.mjs';
export function spriteChecks(
engine,
{ settle, until, nadirView, centre, readRegion, makeCanvas },
) {
return [
[
'movers.createSet: three sprites draw at their projections, hide, recolour and go',
async () => {
await nadirView(1e6);
const atlasCanvas = makeCanvas
? makeCanvas(16, 16)
: { width: 16, height: 16 };
if (makeCanvas) {
const context = atlasCanvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 16, 16);
}
const places = [
[20, 10],
[21.5, 10],
[20, 11.5],
].map(([lon, lat]) => cartesian3.fromDegrees(lon, lat, 1000));
const set = engine.movers.createSet({
id: 'contract-sprites',
kind: 'sprite',
capacity: 3,
order: 100,
depthTest: 'always',
sprite: {
atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } },
sizePx: 24,
},
});
const MAGENTA = [1, 0, 1, 1];
// Frames drawn in a window, with nothing asked of the engine.
const framesIn = async (ms) => {
let frames = 0;
const remove = engine.frames.postRender.addEventListener(
() => frames++,
);
await new Promise((resolve) => setTimeout(resolve, ms));
remove();
return frames;
};
const idleFrames = readRegion ? await framesIn(2000) : 0;
try {
places.forEach((position, index) =>
set.set(index, { position, color: MAGENTA, cell: 'box' }),
);
set.setCount(3);
set.commit();
ensure(set.count() === 3, `count ${set.count()}`);
ensure(
typeof set.ready() === 'boolean' &&
typeof set.ready(0) === 'boolean',
'ready() and ready(index) answer booleans',
);
ensure(
engine.movers
.list()
.some(
({ id, count }) => id === 'contract-sprites' && count === 3,
),
'movers.list() does not show the set',
);
if (!readRegion) return;
const screens = places.map((position) =>
engine.projector.worldToWindow(position),
);
// Pixels close to a colour in a 64 px box around a point: their
// count and centroid.
// A colour as drawn: within 40 of the asked one on every channel
// (both engines draw overlays after tone mapping, unlit).
const find = (screen, colour) => {
const half = 32;
const x0 = Math.round(screen.x) - half;
const y0 = Math.round(screen.y) - half;
const data = readRegion(x0, y0, 2 * half, 2 * half);
let count = 0;
let sx = 0;
let sy = 0;
for (let y = 0; y < 2 * half; y++)
for (let x = 0; x < 2 * half; x++) {
const i = (y * 2 * half + x) * 4;
if (like(colour, data, i)) {
count++;
sx += x0 + x + 0.5;
sy += y0 + y + 0.5;
}
}
return count
? { count, x: sx / count, y: sy / count }
: { count, x: NaN, y: NaN };
};
// The atlas and the upload may take a few frames.
await until(
() => find(screens[0], MAGENTA).count > 100,
'the first sprite drawn',
);
screens.forEach((screen, index) => {
const seen = find(screen, MAGENTA);
ensure(
seen.count > 300,
`sprite ${index}: ${seen.count} magenta pixels, a 24 px square has 576`,
);
ensure(
Math.hypot(seen.x - screen.x, seen.y - screen.y) <= 2,
`sprite ${index} centred at ${seen.x.toFixed(1)}, ${seen.y.toFixed(1)}, projected ${screen.x.toFixed(1)}, ${screen.y.toFixed(1)}`,
);
});
set.set(1, { show: false });
set.set(0, { color: [0, 1, 0, 1] });
set.commit();
await until(
() => find(screens[1], MAGENTA).count < 10,
'the hidden sprite gone',
);
ensure(
find(screens[0], [0, 1, 0, 1]).count > 300,
'the recoloured sprite is not green',
);
ensure(
find(screens[2], MAGENTA).count > 300,
'an untouched sprite changed',
);
// Idle: once a commit has drawn, the set adds no frames (the
// page may draw for its own reasons: the app's other layers).
await settle(2);
const framesAfter = await framesIn(2000);
ensure(
framesAfter <= idleFrames + 1,
`${framesAfter} frames in 2 s after a commit, ${idleFrames} before the set`,
);
set.destroy();
await until(
() => find(screens[2], MAGENTA).count < 10,
'the destroyed set gone',
);
} finally {
set.destroy();
}
ensure(
!engine.movers.list().some(({ id }) => id === 'contract-sprites'),
'a destroyed set is still listed',
);
},
],
[
'movers.createSet: colours: mid-tone sprite, point and line colours draw as asked (within 3 per channel)',
async () => {
if (!readRegion || !makeCanvas) return;
await nadirView(1e6);
const at = (lon, lat, height = 20_000) =>
cartesian3.fromDegrees(lon, lat, height);
const GREY = [0.5, 0.5, 0.5, 1];
const ORANGE = [1, 0.6, 0.2, 1];
const atlasCanvas = makeCanvas(16, 16);
const context = atlasCanvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, 16, 16);
const sprites = engine.movers.createSet({
id: 'contract-colour-sprites',
kind: 'sprite',
capacity: 2,
order: 100,
depthTest: 'always',
sprite: {
atlas: { canvas: atlasCanvas, cells: { box: [0, 0, 1, 1] } },
sizePx: 32,
},
});
const points = engine.movers.createSet({
id: 'contract-colour-points',
kind: 'point',
capacity: 2,
order: 100,
depthTest: 'always',
point: { sizePx: 32 },
});
const lines = [GREY, ORANGE].map((color, index) =>
engine.lines.createLine({
id: `contract-colour-line-${index}`,
widthPx: 16,
color,
}),
);
// The centre 4×4 pixels' mean, as 0..255 per channel.
const centre = (position) => {
const screen = engine.projector.worldToWindow(position);
const data = readRegion(
Math.round(screen.x) - 2,
Math.round(screen.y) - 2,
4,
4,
);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let k = 0; k < 3; k++) sum[k] += data[i + k];
return sum.map((value) => value / (data.length / 4));
};
const cases = [
['sprite', GREY, at(19.5, 10.5)],
['sprite', ORANGE, at(20.5, 10.5)],
['point', GREY, at(19.5, 9.5)],
['point', ORANGE, at(20.5, 9.5)],
['line', GREY, at(19.5, 10)],
['line', ORANGE, at(20.5, 10)],
];
try {
sprites.set(0, { position: cases[0][2], color: GREY, cell: 'box' });
sprites.set(1, { position: cases[1][2], color: ORANGE, cell: 'box' });
sprites.setCount(2);
sprites.commit();
points.set(0, { position: cases[2][2], color: GREY });
points.set(1, { position: cases[3][2], color: ORANGE });
points.setCount(2);
points.commit();
// Short vertical lines through their sample points.
lines[0].setPositions([at(19.5, 9.9), at(19.5, 10.1)]);
lines[1].setPositions([at(20.5, 9.9), at(20.5, 10.1)]);
const off = (seen, colour) =>
Math.max(
...seen.map((value, k) => Math.abs(value - colour[k] * 255)),
);
await until(
() => cases.every(([, colour, p]) => off(centre(p), colour) <= 3),
'mid-tone colours within 3',
);
} catch (error) {
const seen = cases
.map(
([kind, colour, p]) =>
`${kind} ${colour.slice(0, 3)} → ${centre(p)
.map((v) => v.toFixed(0))
.join('/')}`,
)
.join('; ');
throw new ContractError(`${error.message}: ${seen}`);
} finally {
sprites.destroy();
points.destroy();
for (const line of lines) line.destroy();
}
},
],
];
}
export function moverChecks(
engine,
{ until, nadirView, centre, readRegion, makeCanvas },
) {
const { camera } = engine;
return [
[
'movers.createSet: depth tests, rotation, cells, scale, count, points and models',
async () => {
await nadirView(1e6);
const at = (lon, lat, height = 20_000) =>
cartesian3.fromDegrees(lon, lat, height);
// Atlas: 'red' a red square; 'up' white in its top half only.
const atlas = makeCanvas
? makeCanvas(32, 16)
: { width: 32, height: 16 };
if (makeCanvas) {
const context = atlas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 16, 16);
context.fillStyle = '#ffffff';
context.fillRect(16, 0, 16, 8);
}
const cells = { red: [0, 0, 0.5, 1], up: [0.5, 0, 1, 1] };
const sprites = (id, extra = {}) =>
engine.movers.createSet({
id,
kind: 'sprite',
capacity: 3,
order: 100,
depthTest: 'always',
sprite: { atlas: { canvas: atlas, cells }, sizePx: 24 },
...extra,
});
const sets = [];
const make = (set) => (sets.push(set), set);
const RED = [1, 0, 0];
const MAGENTA = [1, 0, 1];
// Pixels near a colour (within 60 per channel) in a box, and how
// many lie above / below the box's centre row.
const look = (position, colour, half = 40) => {
const screen = engine.projector.worldToWindow(position);
const x0 = Math.round(screen.x) - half;
const y0 = Math.round(screen.y) - half;
const data = readRegion(x0, y0, 2 * half, 2 * half);
let total = 0;
let above = 0;
for (let y = 0; y < 2 * half; y++)
for (let x = 0; x < 2 * half; x++) {
const i = (y * 2 * half + x) * 4;
if (
colour.every(
(channel, k) => Math.abs(data[i + k] - channel * 255) <= 60,
)
) {
total++;
if (y < half) above++;
}
}
return { total, above, below: total - above };
};
try {
const a = at(19.5, 10);
const base = make(sprites('contract-cells'));
base.set(0, { position: a, cell: 'red' });
base.set(1, { position: at(20.5, 10), cell: 'red' });
base.set(2, { position: at(20.5, 11), cell: 'red' });
base.setCount(3);
base.commit();
const far = make(
sprites('contract-far', {
sprite: {
atlas: { canvas: atlas, cells },
sizePx: 24,
// The camera is 1000 km away: past `far`, half size.
scaleByDistance: [1e3, 1, 1e5, 0.5],
},
}),
);
const b = at(19.5, 11);
far.set(0, { position: b, cell: 'red' });
far.setCount(1);
far.commit();
const deep = (lon) => at(lon, 9, -30_000);
const depth = {
normal: make(sprites('contract-normal', { depthTest: 'normal' })),
always: make(sprites('contract-always', { depthTest: 'always' })),
within: make(
sprites('contract-within', {
depthTest: { disableWithinM: 2e7 },
}),
),
near: make(
sprites('contract-near', { depthTest: { disableWithinM: 10 } }),
),
};
Object.values(depth).forEach((set, index) => {
set.set(0, { position: deep(19 + index * 0.7), cell: 'red' });
set.setCount(1);
set.commit();
});
const points = make(
engine.movers.createSet({
id: 'contract-points',
kind: 'point',
capacity: 1,
depthTest: 'always',
point: { sizePx: 20, outlinePx: 3 },
}),
);
const p = at(20.5, 9.5);
points.set(0, { position: p, color: [1, 0, 1, 1] });
points.setCount(1);
points.commit();
if (!readRegion) {
base.setCount(1);
base.commit();
ensure(base.count() === 1, 'setCount did not shrink the set');
return;
}
await until(() => look(a, RED).total > 300, 'the red cell drawn');
const unit = look(a, RED).total;
ensure(
look(at(20.5, 11), RED).total > 300,
'the third sprite is not drawn',
);
// Scale by distance: a quarter of the area.
const farArea = look(b, RED).total;
ensure(
farArea > unit * 0.12 && farArea < unit * 0.4,
`scaleByDistance drew ${farArea} px against ${unit} at full size`,
);
// Scale 2: four times the area.
base.set(0, { scale: 2 });
base.commit();
await until(
() => look(a, RED).total > unit * 3,
'the sprite drawn at scale 2',
);
ensure(
look(a, RED).total < unit * 5,
`scale 2 drew ${look(a, RED).total} px against ${unit}`,
);
// Cell switch: the 'up' cell is white in its top half (magenta
// times the instance colour), then turned upside down.
base.set(0, { scale: 1, cell: 'up', color: [1, 0, 1, 1] });
base.commit();
await until(() => look(a, MAGENTA).total > 100, 'the up cell drawn');
const upright = look(a, MAGENTA);
ensure(
upright.above > 10 * Math.max(1, upright.below) &&
look(a, RED).total < 10,
`the up cell: ${JSON.stringify(upright)}, red ${look(a, RED).total}`,
);
base.set(0, { rotation: Math.PI });
base.commit();
await until(
() => look(a, MAGENTA).below > 100,
'the rotated cell drawn',
);
const turned = look(a, MAGENTA);
ensure(
turned.below > 10 * Math.max(1, turned.above),
`rotation π: ${JSON.stringify(turned)}`,
);
// setCount shrinks: the others go.
base.setCount(1);
base.commit();
await until(
() => look(at(20.5, 11), RED).total < 10,
'the shrunk set drawn',
);
ensure(look(a, MAGENTA).total > 100, 'instance 0 went with them');
// Depth: under the ground only 'always' and a disableWithinM
// reaching past the camera draw.
const drawn = (index) =>
look(deep(19 + index * 0.7), RED).total > 100;
await until(() => drawn(1), "the 'always' sprite drawn");
ensure(drawn(2), 'disableWithinM past the camera is hidden');
// Cesium's billboards under the app's photoreal tiles (globe
// hidden) are not occluded by them, measured; Navara's overlay
// tests the scene depth. Occlusion is checked on Navara only.
if (engine.kind === 'navara') {
ensure(!drawn(0), "a 'normal' sprite under the ground is drawn");
ensure(!drawn(3), 'disableWithinM 10 m under the ground is drawn');
}
// Points: a magenta disc with a dark outline.
await until(() => look(p, MAGENTA, 16).total > 80, 'the point drawn');
const ring = look(p, [0, 0, 0], 16).total;
ensure(ring > 20, `no dark outline around the point (${ring} px)`);
// Models: an aircraft drawn in magenta (blended by replace), at
// least 80 px; lit and graded on Navara, so magenta-leaning.
const models = make(
engine.movers.createSet({
id: 'contract-models',
kind: 'model',
capacity: 1,
model: {
url: '/models/airplane.glb',
minimumPixelSize: 80,
colorBlend: { mode: 'replace', amount: 1 },
},
}),
);
const m = at(19.2, 9.6, 50_000);
models.set(0, { position: m, color: [1, 0, 1, 1] });
models.setCount(1);
models.commit();
const modelPixels = () => {
const screen = engine.projector.worldToWindow(m);
const data = readRegion(
Math.round(screen.x) - 50,
Math.round(screen.y) - 50,
100,
100,
);
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (
Math.min(data[i], data[i + 2]) - data[i + 1] > 40 &&
data[i] > 60
)
count++;
return count;
};
// ready() turns true once the glTF has loaded (the magenta point
// and sprites above can already fill the region before then).
await until(() => models.ready(), 'the model set ready()');
ensure(
models.ready(0) === true,
'the drawn instance is not ready(0)',
);
await until(() => modelPixels() > 150, 'the model drawn');
} finally {
for (const set of sets) set.destroy();
}
},
],
];
}
export function failedModelChecks(engine, { settle, until, fixtures }) {
return [
[
'MoverSet.failed: a failed model stays unready and reports its failure scope',
async () => {
const id = 'contract-failed-model';
const set = engine.movers.createSet({
id,
kind: 'model',
capacity: 2,
model: { url: '/__contract_missing_model.glb' },
});
try {
set.set(0, {
position: cartesian3.fromDegrees(20, 10, 1000),
pickId: 'failed',
});
set.setCount(1);
set.commit();
fixtures?.failModel?.(id, true);
await until(() => set.failed(0), 'failed model');
ensure(set.failed() === true, 'set did not report the failure');
ensure(
set.ready(0) === false && set.ready() === false,
'failed model is ready',
);
ensure(
set.failed(1) === (engine.kind === 'navara'),
'failure scope changed',
);
await settle(3);
ensure(
set.failed(0) && !set.ready(0),
'failed model silently recovered',
);
} finally {
fixtures?.failModel?.(id, false);
set.destroy();
}
ensure(
!set.failed() && !set.ready(),
'destroyed model reports ready or failed',
);
},
],
];
}
export function moverListChecks(engine) {
return [
[
'movers.list: shown follows active instance visibility and count',
async () => {
const set = engine.movers.createSet({
id: 'contract-movers-shown',
kind: 'point',
capacity: 2,
point: { sizePx: 8 },
});
const shown = () =>
engine.movers.list().find((row) => row.id === set.id)?.shown;
try {
ensure(shown() === false, 'empty set is shown');
set.set(0, { position: cartesian3.fromDegrees(20, 10), show: false });
set.setCount(1);
set.commit();
ensure(shown() === false, 'hidden set is shown');
set.set(0, { show: true });
set.commit();
ensure(shown() === true, 'visible set is hidden');
set.setCount(0);
set.commit();
ensure(shown() === false, 'zero count is shown');
} finally {
set.destroy();
}
ensure(
!engine.movers.list().some((row) => row.id === set.id),
'removed set remains listed',
);
},
],
];
}
+377
View File
@@ -0,0 +1,377 @@
import { ensure } from './helpers.mjs';
export function renderChecks(engine, { settle }) {
return [
[
'render.setContinuous: continuous mode toggles',
async () => {
const previous = engine.render.isContinuous();
try {
engine.render.setContinuous(true);
ensure(
engine.render.isContinuous() === true,
'continuous not enabled',
);
engine.render.setContinuous(false);
ensure(
engine.render.isContinuous() === false,
'continuous not disabled',
);
} finally {
engine.render.setContinuous(previous);
}
},
],
[
'render.setSharpen: merges partial updates, rejects bad values, draws',
async () => {
const previous = engine.render.sharpen();
const same = (expected, label) => {
const actual = engine.render.sharpen();
ensure(
actual.enabled === expected.enabled &&
actual.amount === expected.amount,
`${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`,
);
};
ensure(
typeof previous.enabled === 'boolean' &&
Number.isFinite(previous.amount),
`sharpen() ${JSON.stringify(previous)} is not { enabled, amount }`,
);
try {
engine.render.setSharpen({ enabled: true, amount: 1.7 });
same({ enabled: true, amount: 1.7 }, 'after enabling');
engine.render.sharpen().amount = 0;
same({ enabled: true, amount: 1.7 }, 'sharpen() is a copy');
// A frame with the sharpening on draws without errors.
await settle(2);
engine.render.setSharpen({ amount: 0.5 });
same({ enabled: true, amount: 0.5 }, 'after an amount alone');
engine.render.setSharpen({ enabled: false });
same({ enabled: false, amount: 0.5 }, 'after disabling');
for (const bad of [
{ enabled: 'yes' },
{ amount: -0.1 },
{ amount: Number.NaN },
{ amount: '1' },
]) {
let error = null;
try {
engine.render.setSharpen(bad);
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setSharpen(${JSON.stringify(bad)}) did not throw a TypeError`,
);
}
same({ enabled: false, amount: 0.5 }, 'after rejected values');
await settle(1);
} finally {
engine.render.setSharpen(previous);
await settle(1);
}
},
],
];
}
export function contactSonarChecks(render, settle) {
return [
[
'render.setContactSonar: (contactSonar) merges, copies, rejects bad values, draws',
async () => {
const previous = render.contactSonar();
ensure(
previous.enabled === false,
`the sonar starts ${JSON.stringify(previous)}`,
);
try {
render.setContactSonar({
enabled: true,
active: true,
angleDeg: 90,
range: 100,
opacity: 40,
sector: 30,
});
await settle(2);
render.setContactSonar({ angleDeg: 180 });
const state = render.contactSonar();
ensure(
state.enabled && state.active && state.angleDeg === 180,
`after an angle alone: ${JSON.stringify(state)}`,
);
state.angleDeg = 0;
ensure(
render.contactSonar().angleDeg === 180,
'contactSonar() is a copy',
);
for (const bad of [
{ enabled: 'yes' },
{ active: 1 },
{ angleDeg: Number.NaN },
{ range: 0 },
{ opacity: 101 },
{ sector: 0 },
{ sector: 400 },
]) {
let error = null;
try {
render.setContactSonar(bad);
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setContactSonar(${JSON.stringify(bad)}) did not throw a TypeError`,
);
}
ensure(
render.contactSonar().opacity === 40,
'a rejected call changed the sonar',
);
} finally {
render.setContactSonar(previous);
await settle(1);
}
},
],
];
}
// The optional cloudCover capability (render.setCloudCover).
export function cloudCoverChecks(render, settle) {
return [
[
'render.setCloudCover: (cloudCover) sets, copies, gives back, rejects bad values',
async () => {
const previous = render.cloudCover();
try {
render.setCloudCover({ coverage: 0.7 });
ensure(render.cloudCover().coverage === 0.7, 'coverage not set');
render.cloudCover().coverage = 0;
ensure(
render.cloudCover().coverage === 0.7,
'cloudCover() is a copy',
);
await settle(2);
render.setCloudCover({});
ensure(render.cloudCover().coverage === 0.7, 'omitted keeps it');
render.setCloudCover({ coverage: null });
ensure(render.cloudCover().coverage === null, 'null: the engine own');
for (const bad of [-0.1, 1.2, Number.NaN, '0.5']) {
let error = null;
try {
render.setCloudCover({ coverage: bad });
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setCloudCover(${bad}) did not throw a TypeError`,
);
}
ensure(
render.cloudCover().coverage === null,
'after rejected values',
);
} finally {
render.setCloudCover(previous);
await settle(1);
}
},
],
];
}
// The optional bloom capability (render.setBloom).
export function bloomChecks(render, settle) {
return [
[
'render.setBloom: (bloom) merges, copies, rejects bad values, draws',
async () => {
const previous = render.bloom();
ensure(
typeof previous.enabled === 'boolean' &&
Number.isFinite(previous.strength),
`bloom() ${JSON.stringify(previous)} is not { enabled, strength }`,
);
const same = (expected, label) => {
const actual = render.bloom();
ensure(
actual.enabled === expected.enabled &&
actual.strength === expected.strength,
`${label}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`,
);
};
try {
render.setBloom({ enabled: true, strength: 0.8 });
same({ enabled: true, strength: 0.8 }, 'after enabling');
render.bloom().strength = 0;
same({ enabled: true, strength: 0.8 }, 'bloom() is a copy');
// A frame with the bloom on draws without errors.
await settle(2);
render.setBloom({ strength: 0.05 });
same({ enabled: true, strength: 0.05 }, 'in the dead zone');
render.setBloom({ enabled: false });
same({ enabled: false, strength: 0.05 }, 'after disabling');
for (const bad of [
{ enabled: 1 },
{ strength: -0.1 },
{ strength: 1.5 },
{ strength: Number.NaN },
]) {
let error = null;
try {
render.setBloom(bad);
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setBloom(${JSON.stringify(bad)}) did not throw a TypeError`,
);
}
same({ enabled: false, strength: 0.05 }, 'after rejected values');
} finally {
render.setBloom(previous);
await settle(1);
}
},
],
];
}
// The optional visualStyles capability (render.setStyle).
export function styleChecks(render, settle) {
return [
[
'render.setStyle: (visualStyles) merges, copies, rejects bad values, draws',
async () => {
const names = render.visualStyles;
ensure(
names.length > 0 && new Set(names).size === names.length,
`visualStyles ${JSON.stringify(names)} is not a list of names`,
);
const [name] = names;
const previous = render.style(name);
ensure(
previous.intensity === 0,
`${name} starts at intensity ${previous.intensity}`,
);
const [uniform] = Object.keys(previous.uniforms);
try {
render.setStyle(name, { intensity: 1 });
ensure(render.style(name).intensity === 1, 'intensity not set');
// A frame with the style on draws without errors.
await settle(2);
if (uniform) {
render.setStyle(name, { uniforms: { [uniform]: 0.25 } });
const state = render.style(name);
ensure(
state.intensity === 1 && state.uniforms[uniform] === 0.25,
`after a uniform alone: ${JSON.stringify(state)}`,
);
state.uniforms[uniform] = 9;
ensure(
render.style(name).uniforms[uniform] === 0.25,
'style() is a copy',
);
}
render.setStyle(name, { intensity: 0.5 });
ensure(render.style(name).intensity === 0.5, 'crossfade step');
for (const [label, call] of [
['unknown style', () => render.setStyle('no-such-style', {})],
['intensity 2', () => render.setStyle(name, { intensity: 2 })],
[
'intensity -0.1',
() => render.setStyle(name, { intensity: -0.1 }),
],
[
'unknown uniform',
() => render.setStyle(name, { uniforms: { noSuchUniform: 1 } }),
],
...(uniform
? [
[
'NaN uniform',
() =>
render.setStyle(name, {
uniforms: { [uniform]: Number.NaN },
}),
],
]
: []),
]) {
let error = null;
try {
call();
} catch (caught) {
error = caught;
}
ensure(
error instanceof TypeError,
`setStyle with ${label} did not throw a TypeError`,
);
}
ensure(
render.style(name).intensity === 0.5,
'a rejected call changed the style',
);
} finally {
render.setStyle(name, previous);
await settle(1);
}
},
],
];
}
export function renderBehaviourChecks(engine, { waitFrames }) {
return [
[
'render.maxTextureSize: a positive power of two',
async () => {
const size = engine.render.maxTextureSize();
ensure(
Number.isInteger(size) &&
size > 0 &&
Number.isInteger(Math.log2(size)),
`texture limit ${size}`,
);
},
],
[
'render.request: a request inside preRender draws the next frame',
async () => {
const previous = engine.render.isContinuous();
let drawn = 0;
let requested = false;
const removePre = engine.frames.preRender.addEventListener(() => {
if (requested) return;
requested = true;
engine.render.request('contract-next-frame');
});
const removePost = engine.frames.postRender.addEventListener(
() => drawn++,
);
try {
engine.render.setContinuous(false);
engine.render.request('contract-first-frame');
await waitFrames(2);
ensure(drawn >= 2, 'the request inside the frame was lost');
} finally {
removePre();
removePost();
engine.render.setContinuous(previous);
}
},
],
];
}
+303
View File
@@ -0,0 +1,303 @@
import {
cartesian2,
cartesian3,
cartographic,
ellipsoid,
math,
WGS84,
ensure,
near,
isCartesian3,
} from './helpers.mjs';
export function surfaceChecks(engine, { nadirView, centre }) {
return [
[
'picker.pick: pick, drillPick and pickPosition return contract shapes',
async () => {
await nadirView();
const screen = cartesian2.create(1, 1);
const picked = engine.picker.pick(screen);
ensure(
picked === undefined || 'id' in picked,
'pick returned no id field',
);
const drilled = engine.picker.drillPick(screen, 3);
ensure(Array.isArray(drilled), 'drillPick did not return an array');
ensure(drilled.length <= 3, 'drillPick ignored its limit');
ensure(
typeof engine.picker.pickPositionSupported === 'boolean',
'pickPositionSupported is not boolean',
);
const position = engine.picker.pickPosition(centre());
ensure(
position === undefined || isCartesian3(position),
'pickPosition is not a Cartesian3',
);
},
],
[
'surface.sampleHeightDetailed: source, tile size and mover-set exclusion',
async () => {
const place = cartographic.fromDegrees(20, 10, 0);
const shape = (detail, label) => {
if (detail === undefined) return;
ensure(Number.isFinite(detail.height), `${label}: height`);
ensure(
detail.source === 'tiles' || detail.source === 'terrain',
`${label}: source ${detail.source}`,
);
ensure(
detail.tileSizeM === undefined ||
(detail.source === 'tiles' && detail.tileSizeM > 0),
`${label}: tileSizeM ${detail.tileSizeM}`,
);
};
const visible = engine.surface.sampleHeightDetailed(place);
shape(visible, 'visible');
if (visible?.source === 'tiles')
near(
engine.surface.sampleHeight(place),
visible.height,
1e-6,
'sampleHeight and the detailed tiles answer',
);
// A mover set in exclude: the sample works and the set stays.
const set = engine.movers.createSet({
id: 'contract-surface-exclude',
kind: 'point',
capacity: 1,
point: { sizePx: 8 },
});
try {
set.set(0, { position: cartesian3.fromDegrees(20, 10, 50) });
set.setCount(1);
set.commit();
shape(
engine.surface.sampleHeightDetailed(place, { exclude: [set] }),
'excluding a set',
);
ensure(set.id === 'contract-surface-exclude', 'the set has no id');
ensure(
engine.movers
.list()
.some(({ id }) => id === 'contract-surface-exclude'),
'the excluded set is gone',
);
} finally {
set.destroy();
}
},
],
[
'surface.sampleHeight: flags, tilesLoaded, sampleHeight, terrainHeight and clampToHeight shapes',
async () => {
for (const flag of ['terrainHeightSupported', 'clampToHeightSupported'])
ensure(
typeof engine.surface[flag] === 'boolean',
`${flag} is not boolean`,
);
const place = cartographic.fromDegrees(20, 10, 0);
if (engine.surface.terrainHeightSupported) {
const terrain = engine.surface.terrainHeight(place);
ensure(
terrain === undefined || Number.isFinite(terrain),
`terrainHeight returned ${terrain}`,
);
}
if (engine.surface.clampToHeightSupported) {
const clamped = engine.surface.clampToHeight(
cartesian3.fromDegrees(20, 10, 5000),
);
ensure(
clamped === undefined || isCartesian3(clamped),
'clampToHeight is not a Cartesian3',
);
}
ensure(
typeof engine.surface.supported === 'boolean',
'supported is not boolean',
);
ensure(
typeof engine.surface.tilesLoaded() === 'boolean',
'tilesLoaded is not boolean',
);
if (!engine.surface.supported) return;
const value = engine.surface.sampleHeight(
cartographic.fromDegrees(20, 10, 0),
);
ensure(
value === undefined || Number.isFinite(value),
`sampleHeight returned ${value}`,
);
},
],
];
}
export function globePickChecks(engine, { settle, until, nadirView, centre }) {
const { camera } = engine;
return [
[
'picker.pickGlobe: the nadir hits the globe and the sky misses',
async () => {
await nadirView();
const hit = engine.picker.pickGlobe(centre());
ensure(isCartesian3(hit), 'the nadir missed the globe');
const point = cartographic.fromCartesian(hit, WGS84);
// Terrain, when shown, stays within Earth's relief of the ellipsoid.
near(point.height, 0, 9000, 'globe height (m)');
near(math.toDegrees(point.longitude), 20, 0.05, 'longitude (deg)');
near(math.toDegrees(point.latitude), 10, 0.05, 'latitude (deg)');
camera.setView({
destination: cartesian3.fromDegrees(20, 10, 1e6),
orientation: { heading: 0, pitch: 0.6, roll: 0 },
});
await settle();
ensure(
engine.picker.pickGlobe(centre()) === undefined,
'the sky hit the globe',
);
},
],
[
'picker.pickPosition: projector.worldToWindow round-trip within 1.5 px',
async () => {
if (!engine.picker.pickPositionSupported) return;
// Straight down and tilted; screen points with no surface are skipped.
// Over New York, low enough for map detail.
const views = [
{ height: 20000, heading: 0, pitch: -math.PI_OVER_TWO },
{ height: 5000, heading: 0.7, pitch: -1.0 },
];
let hits = 0;
const misses = [];
for (const { height: viewHeight, heading, pitch } of views) {
camera.setView({
destination: cartesian3.fromDegrees(-73.78, 40.64, viewHeight),
orientation: { heading, pitch, roll: 0 },
});
// Picks read the depth of the last drawn frame: draw this view.
await settle(3);
const { width, height } = engine.host.size();
const screens = [
[0.5, 0.5],
[0.3, 0.4],
[0.7, 0.6],
[0.5, 0.75],
].map(([x, y]) =>
cartesian2.create(Math.round(width * x), Math.round(height * y)),
);
// The depth a pick reads needs drawn frames.
await until(
() => engine.picker.pickPosition(screens[0]) !== undefined,
'a surface under the centre for pickPosition',
);
for (const screen of screens) {
const position = engine.picker.pickPosition(screen);
if (position === undefined) continue;
const back = engine.projector.worldToWindow(position);
const error = back
? Math.hypot(back.x - screen.x, back.y - screen.y)
: Infinity;
hits += 1;
if (!(error <= 1.5))
misses.push(
`(${screen.x}, ${screen.y}) → ${back ? `(${back.x.toFixed(2)}, ${back.y.toFixed(2)})` : 'undefined'}`,
);
}
}
ensure(hits > 0, 'pickPosition hit no surface');
ensure(
misses.length === 0,
`round-trip off by more than 1.5 px: ${misses.join('; ')}`,
);
},
],
];
}
export function terrainSampleChecks(engine, { fixtures }) {
const checks = [
[
'surface.sampleTerrainMostDetailed: an empty batch stays empty',
async () => {
const result = await engine.surface.sampleTerrainMostDetailed([]);
ensure(
Array.isArray(result) && result.length === 0,
'empty terrain batch',
);
},
],
[
'surface.raycast: a ray away from the globe misses',
async () => {
const origin = cartesian3.fromDegrees(20, 10, 1e6);
const hit = engine.surface.raycast({
origin,
direction: cartesian3.normalize(origin, cartesian3.create()),
far: 1000,
});
ensure(hit === undefined, 'outward ray hit a surface');
},
],
];
if (fixtures?.terrain) {
const { surface, sample } = fixtures.terrain;
checks.push(
[
'surface.sampleTerrainMostDetailed: preserves order and unknown heights',
async () => {
const positions = [2, -1, 1].map((longitude) => ({
longitude,
latitude: 0,
height: 99,
}));
sample((place) =>
place.longitude < 0 ? undefined : place.longitude * 10,
);
try {
const result = await surface.sampleTerrainMostDetailed(positions);
ensure(
result.length === 3 &&
Math.abs(result[0] - 20) < 1e-10 &&
result[1] === undefined &&
Math.abs(result[2] - 10) < 1e-10,
`terrain samples ${result}`,
);
ensure(
positions.every((place) => place.height === 99),
'sampling changed the caller positions',
);
} finally {
sample(null);
}
},
],
[
'surface.sampleTerrainMostDetailed: passes sampling rejection to the caller',
async () => {
const failure = new Error('terrain sample unavailable');
sample(() => {
throw failure;
});
let caught;
try {
await surface.sampleTerrainMostDetailed([
{ longitude: 0, latitude: 0 },
]);
} catch (error) {
caught = error;
} finally {
sample(null);
}
ensure(
caught === failure,
'sampling rejection was swallowed or replaced',
);
},
],
);
}
return checks;
}
+33 -2
View File
@@ -202,6 +202,7 @@ export function createFakeEngine(options = {}) {
up: cartesian3.create(),
};
const events = {
'maps.errors': createEngineEvent(),
'camera.changed': createEngineEvent(),
'camera.moveStart': createEngineEvent(),
'camera.moveEnd': createEngineEvent(),
@@ -263,6 +264,8 @@ export function createFakeEngine(options = {}) {
part.commits++;
fake.renderRequests++;
},
ready: (index) =>
!scripted('moverFailed', [options.id, index], () => false),
// Scripted as 'moverFailed' (set id, index) for a load that fails.
failed: (index) =>
scripted('moverFailed', [options.id, index], () => false),
@@ -327,6 +330,20 @@ export function createFakeEngine(options = {}) {
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
const livePosition = cartesian3.create();
let lastBaseImagery = null;
const removeMapErrors = { tile: null, terrain: null };
function watchMapErrors(source, kind) {
removeMapErrors[kind]?.();
removeMapErrors[kind] = source?.provider?.errorEvent?.addEventListener(
(error) => {
events['maps.errors'].raiseEvent({
source,
kind,
status:
error?.error?.statusCode ?? error?.statusCode ?? error?.status,
});
},
);
}
const scripts = new Map();
let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5;
@@ -465,11 +482,13 @@ export function createFakeEngine(options = {}) {
const pose = {
position: copy3(state.position),
direction: copy3(state.direction),
up: copy3(state.up),
};
const differs = (a, b) =>
!b ||
!cartesian3.equalsEpsilon(a.position, b.position, 0, 1e-9) ||
!cartesian3.equalsEpsilon(a.direction, b.direction, 0, 1e-12);
!cartesian3.equalsEpsilon(a.direction, b.direction, 0, 1e-12) ||
!cartesian3.equalsEpsilon(a.up, b.up, 0, 1e-12);
if (differs(pose, lastFramePose)) {
if (!moving) {
moving = true;
@@ -498,7 +517,11 @@ export function createFakeEngine(options = {}) {
);
const shift =
cartesian3.distance(pose.position, lastChangedPose.position) / altitude;
const amount = Math.max(turn, shift);
const roll =
Math.acos(
math.clamp(cartesian3.dot(pose.up, lastChangedPose.up), -1, 1),
) / Math.PI;
const amount = Math.max(turn, roll, shift);
if (amount > percentageChanged) {
lastChangedPose = pose;
events['camera.changed'].raiseEvent(amount);
@@ -708,6 +731,7 @@ export function createFakeEngine(options = {}) {
},
});
const groundPicks = createGroundPicks({
surface: () => (fake.maps.photoreal != null ? 'tiles' : 'terrain'),
pickGlobe: (screen) => engine.picker.pickGlobe(screen),
camera: {
get livePosition() {
@@ -909,10 +933,12 @@ export function createFakeEngine(options = {}) {
// The contract's switch order: a call shows its source when made (the
// fake builds nothing), so the call made last decides what shows.
maps: {
errors: events['maps.errors'],
async setBaseImagery(source) {
record('maps.setBaseImagery', [source]);
fake.maps.imagery = source;
lastBaseImagery = source;
watchMapErrors(source, 'tile');
},
async setTerrain(source) {
record('maps.setTerrain', [source]);
@@ -925,16 +951,19 @@ export function createFakeEngine(options = {}) {
throw new TypeError(`Unknown terrain source: ${kind}`);
fake.maps.terrain = source;
fake.maps.terrainRevision++;
watchMapErrors(source, 'terrain');
},
async setPhotorealTiles(source) {
record('maps.setPhotorealTiles', [source]);
if (source == null) {
fake.maps.photoreal = null;
fake.maps.imagery = lastBaseImagery;
watchMapErrors(lastBaseImagery, 'tile');
return;
}
fake.maps.photoreal = source;
fake.maps.imagery = null;
watchMapErrors(null, 'tile');
},
releasePhotorealTiles(source) {
record('maps.releasePhotorealTiles', [source]);
@@ -949,6 +978,7 @@ export function createFakeEngine(options = {}) {
},
destroy() {
record('destroy', []);
for (const remove of Object.values(removeMapErrors)) remove?.();
follow.destroy();
cancelCurrentFlight();
moverRegistry.destroyAll();
@@ -1123,6 +1153,7 @@ export function createFakeEngine(options = {}) {
lastFramePose = {
position: copy3(state.position),
direction: copy3(state.direction),
up: copy3(state.up),
};
return engine;
}
+1 -1
View File
@@ -261,7 +261,7 @@ test('fake movers keep their sets and instances for layer tests', () => {
set.set(0, { position: { x: 1, y: 2, z: 3 }, color: [1, 0, 0, 1] });
set.setCount(1);
assert.deepEqual(engine.movers.list(), [
{ id: 'flights', kind: 'point', count: 1, capacity: 4 },
{ id: 'flights', kind: 'point', count: 1, capacity: 4, shown: true },
]);
const before = engine.fake.renderRequests;
set.commit();
+16 -11
View File
@@ -1,3 +1,4 @@
import { engineCapabilities } from '../globe/contract.js';
import { indexMapSources } from './registry.js';
import { acquireImageryComparison } from './imageryComparison.js';
@@ -32,9 +33,9 @@ const resolved = (provider, effectiveStackId) => ({
* and watching them as before (supplied tilesets must be ones the engine
* knows, i.e. its startup tileset, or has shown). Any other engine builds
* its own sources from the descriptions; the controller constructs nothing
* for it, falls back by description when a setter rejects, and has no tile
* failure watch. On destroy the engine keeps what it shows; owned tilesets
* are released and disposed.
* for it and falls back by description when a setter rejects. Tile failure
* fallback runs only with the tileErrors capability. On destroy the engine
* keeps what it shows; owned tilesets are released and disposed.
*/
export class MapSourceController {
constructor({
@@ -340,6 +341,8 @@ export class MapSourceController {
async _activateDescribedGlobeStack(stack, gen, visited = new Set()) {
const { maps } = this._engine;
const source = this._sources.get(stack.id);
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
try {
await maps.setBaseImagery(this._describeImagery(source));
} catch (error) {
@@ -367,7 +370,9 @@ export class MapSourceController {
if (gen !== this._switchGen) return;
this._terrainMode = source.terrain.id;
}
return { effectiveStackId: stack.id, fallbackMessage: null };
const resolution = { effectiveStackId: stack.id, fallbackMessage: null };
this._watchProvider(resolution, gen);
return resolution;
}
/** Engine description of an imagery source (+ a prebuilt Cesium provider). */
@@ -603,24 +608,24 @@ export class MapSourceController {
}
_watchProvider(resolution, gen) {
if (!engineCapabilities(this._engine).tileErrors) return;
const fallback = this._sources.get(
resolution.effectiveStackId,
)?.tileFailureFallback;
const errorEvent = resolution.provider?.errorEvent;
const errorEvent = this._engine.maps.errors;
const source = this._engine.maps.current().baseImagery;
if (!fallback || !errorEvent?.addEventListener) return;
let failures = 0;
let pending = false;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (
gen !== this._switchGen ||
this._liveBaseProvider() !== resolution.provider
error.kind !== 'tile' ||
error.source !== source ||
this._engine.maps.current().baseImagery !== source
)
return;
const retryCount = Number(error?.timesRetried);
failures =
Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
failures++;
if (failures < fallback.threshold || pending) return;
pending = true;
this._onError?.(