mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Check the globe contract by behaviour on every engine
The contract suite is split into one module per area and grows from 87 to 114 checks, each named after the member it exercises: a gesture during a flight does not cut it, a render request inside a frame draws the next one, a pure rotation raises changed, ground corridors are as wide in CSS pixels at device scale 2, imagery replans on resize, drapes draw only on their surface, sampleTerrainMostDetailed keeps order and passes on a rejection, terrainRevision changes on a new terrain and on fallback, follows raise followChanged on retarget, and a mesh, a ground fill and an imagery pixel draw the colour asked for. In the browser Cesium passes all 113 it runs at device scale 1 and 2; Navara passes 110 of 111 and reports its single-colour mesh, drawn before tone mapping, as a known failure. scripts/contract-mutants.test.mjs breaks one promise of the fake engine at a time, 30 mutants in all, and names the check that catches each; the list of contract members the suite never calls is down to five. maps.errors reports tile and terrain errors where an engine has them (the tileErrors capability: Cesium yes, Navara no), and the map controller falls back to another map only where it does; movers.list() rows tell whether a set is shown, as the other families do; FollowHandle .name and PickResult.primitive are marked deprecated. qa-contract-suite takes --dpr and the ANGLE backend from QA_ANGLE.
This commit is contained in:
1 parent
9c2a2d6951
commit
b03edaaf2b
39 files changed
+7247
-5194
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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, () => {});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -388,6 +388,7 @@ export function createFollowController({
|
||||
handle.label = updated.label;
|
||||
handle.offset = null;
|
||||
handle.previous = null;
|
||||
followChanged.raiseEvent(handle);
|
||||
requestRender();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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?.(
|
||||
|
||||
Reference in new issue
Block a user