mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Merge branch 'german/refactor-e3-contract-behaviour' into german/navara-refactor
This commit is contained in:
commit
33ccef9662
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