feat(globe): engine-agnostic maps checks and maps.current

The contract suite gains maps checks (opt-in with mapSources: two imagery
sources and a photoreal one the engine can show): setPhotorealTiles(null)
brings back the last base imagery, the last imagery or photoreal call
decides what shows, and a released photoreal source no longer shows or
counts. They observe the new maps.current(), the source objects on screen
as passed. The fake engine follows the same semantics; qa-globe-contract
runs the checks on the app engine with the startup tileset and reapplies
the app's map stack afterwards. The contract documents map sources as
engine-neutral descriptions whose prebuilt members only the engine kind
that built them uses.
This commit is contained in:
German Khokhlov committed 2026-10-15 19:01:00 +04:00
1 parent 12c9651a67
commit ab0acb0e09
6 files changed
+160 -14

No files matched your search

+9
View File
@@ -95,6 +95,9 @@ export function createCesiumMaps(
// decides what the globe shows. Terrain has its own.
const generations = { display: 0, terrain: 0 };
let baseSource = null;
// The source objects on screen, as passed (maps.current()).
let currentBase = null;
let currentTiles = null;
let creditOwner = null;
let destroyed = false;
@@ -130,6 +133,7 @@ export function createCesiumMaps(
}
function showBase(source, provider) {
currentBase = source;
mapScene.showBaseImagery(provider);
showCredit('imagery', imageryCredit(source));
}
@@ -156,6 +160,7 @@ export function createCesiumMaps(
if (source == null) {
mapScene.showTileset(null, tilesets());
shownTileset = null;
currentTiles = null;
// The globe comes back with the last base imagery and its credit;
// the photoreal credit goes.
if (baseSource) {
@@ -195,6 +200,8 @@ 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();
currentBase = null;
currentTiles = source;
showCredit('photoreal', source.credit);
shownTileset = tileset;
mapScene.showTileset(tileset, tilesets());
@@ -205,9 +212,11 @@ export function createCesiumMaps(
const tileset = source?.tileset;
if (!tileset || !known.delete(tileset)) return;
if (shownTileset === tileset) shownTileset = null;
if (currentTiles?.tileset === tileset) currentTiles = null;
if (added.delete(tileset)) viewer.scene.primitives.remove(tileset);
},
photorealActive: () => tilesets().some((tileset) => tileset.show === true),
current: () => ({ baseImagery: currentBase, photoreal: currentTiles }),
};
return {
+18
View File
@@ -378,3 +378,21 @@ test('releasePhotorealTiles leaves a tileset the engine did not add in the scene
maps.releasePhotorealTiles({ tileset: startup });
assert.equal(viewer.primitiveList.includes(startup), true);
});
test('current names the sources on screen as passed', async () => {
const { maps } = setup();
assert.deepEqual(maps.current(), { baseImagery: null, photoreal: null });
const esri = esriSource();
const tiles = { tileset: tileset(), credit: 'Google' };
await maps.setBaseImagery(esri);
assert.deepEqual(maps.current(), { baseImagery: esri, photoreal: null });
await maps.setPhotorealTiles(tiles);
assert.equal(maps.current().photoreal, tiles);
assert.equal(maps.current().baseImagery, null);
await maps.setPhotorealTiles(null);
assert.equal(maps.current().baseImagery, esri);
assert.equal(maps.current().photoreal, null);
await maps.setPhotorealTiles(tiles);
maps.releasePhotorealTiles(tiles);
assert.equal(maps.current().photoreal, null, 'a released source is gone');
});
+33 -11
View File
@@ -161,6 +161,37 @@
* a surface-pick check reads), else undefined.
* @property {any} [object] The engine's own pick result, for engine-specific code.
*
* @typedef {object} GlobeMaps
* Map sources are engine-neutral descriptions, as maps/registry.js sources
* carry them; each engine builds its own providers from them:
* - imagery `{ kind: 'osm'|'esri-imagery'|'ion', style?, accessToken?,
* credit?, descriptor? }`;
* - photoreal tiles `{ kind: 'photoreal', googleApiKey?, cesiumToken?,
* credit?, descriptor? }`;
* - terrain `{ kind: 'ellipsoid'|'keyless'|'world', accessToken?, id? }`.
* Optional prebuilt members (`provider`, `tileset`, terrain `create`, an
* `imagery()` factory) are engine objects their caller built and owns: only
* the engine kind that built them uses them (the Cesium engine for the
* registry's Cesium factories), any other ignores them and builds from the
* description. The engine neither caches nor disposes them.
* Each setter settles once the source is on screen and rejects when it
* 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 {(source: object) => Promise<void>} setBaseImagery
* @property {(source: object) => Promise<void>} setTerrain
* @property {(source: object | null) => Promise<void>} setPhotorealTiles
* Tiles replace the base imagery and hide the globe; null hides every
* tileset and shows the globe again with the last base imagery and its
* credit (no credit when none was set).
* @property {(source: object) => void} releasePhotorealTiles Forgets a
* photoreal source shown before (it no longer counts for photorealActive)
* and takes engine-added tiles out of the scene; its caller disposes them.
* @property {() => boolean} photorealActive
* @property {() => { baseImagery: object | null, photoreal: object | null }} current
* The source objects on screen, as passed: the base imagery layer on the
* globe and the photoreal tiles shown (null for none).
*
* @typedef {object} GlobeEngine
* @property {'cesium'|'navara'} kind
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
@@ -174,17 +205,7 @@
* As in Cesium, a listener with a modifier fires only while that key is
* held and one without fires only while no modifier is held.
* @property {{ addStatic: (html: string, options?: { onScreen?: boolean }) => () => void }} credits
* @property {{ setBaseImagery: (source: object) => Promise<void>, setTerrain: (source: object) => Promise<void>, setPhotorealTiles: (source: object | null) => Promise<void>, releasePhotorealTiles: (source: object) => void, photorealActive: () => boolean }} maps
* Sources are maps/registry.js descriptions; the engine builds its own
* providers. Each setter settles once the source is on screen and rejects
* when it cannot be built (the current source then stays). A source may
* carry a ready engine `provider` its caller built and owns (the engine
* neither caches nor disposes it). Base imagery and photoreal tiles share
* one switch order: the one asked for last wins, a superseded call settles
* without showing anything. setPhotorealTiles(null) shows the globe again
* with the last base imagery and its credit (no credit when none was set).
* releasePhotorealTiles forgets a tileset source shown before and takes it
* out of the scene when the engine put it there; its caller disposes it.
* @property {GlobeMaps} maps
* @property {() => void} destroy
*/
@@ -364,6 +385,7 @@ const SHAPE = {
setPhotorealTiles: isFunction,
releasePhotorealTiles: isFunction,
photorealActive: isFunction,
current: isFunction,
},
};
+6
View File
@@ -191,6 +191,7 @@ test('the contract carries what the shell infrastructure needs', () => {
'camera.livePosition',
'camera.height',
'maps.releasePhotorealTiles',
'maps.current',
])
assert.ok(GLOBE_ENGINE_MEMBERS.includes(member), member);
const engine = createFakeEngine();
@@ -209,8 +210,13 @@ 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),
mapSources: {
imagery: [{ kind: 'osm' }, { kind: 'esri-imagery' }],
photoreal: { kind: 'photoreal' },
},
});
assert.ok(checks.length >= 15);
assert.ok(checks.some(({ name }) => name.startsWith('maps:')));
assert.equal(new Set(checks.map((check) => check.name)).size, checks.length);
for (const check of checks) await t.test(check.name, () => check.run());
});
+78 -2
View File
@@ -69,12 +69,15 @@ const along = (origin, direction, distance) =>
/**
* @param {import('../contract.js').GlobeEngine} engine
* @param {{ waitFrames: (count: number) => Promise<void>, maxWaitFrames?: number }} options
* @param {{ waitFrames: (count: number) => Promise<void>, maxWaitFrames?: number,
* mapSources?: { imagery: object[], photoreal: object } }} options
* `mapSources` (two imagery sources and a photoreal one the engine can
* show) adds the maps checks; they leave photoreal tiles hidden.
* @returns {{ name: string, run: () => Promise<void> }[]}
*/
export function createContractSuite(
engine,
{ waitFrames, maxWaitFrames = 240 },
{ waitFrames, maxWaitFrames = 240, mapSources = null },
) {
if (typeof waitFrames !== 'function')
throw new TypeError('createContractSuite needs a waitFrames(count) helper');
@@ -1378,9 +1381,82 @@ export function createContractSuite(
},
],
];
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
checks.push(...mapChecks(engine.maps, mapSources));
return checks.map(([name, run]) => ({ name, run }));
}
// The maps switch order, observed through maps.current().
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: 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: 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');
},
],
];
}
/** Runs every check in order; never throws for a failing check. */
export async function runContractSuite(engine, options) {
const results = [];
+16 -1
View File
@@ -241,6 +241,7 @@ export function createFakeEngine(options = {}) {
};
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
const livePosition = cartesian3.create();
let lastBaseImagery = null;
const scripts = new Map();
let transform = [...matrix4.IDENTITY];
let percentageChanged = 0.5;
@@ -819,10 +820,13 @@ 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: {
async setBaseImagery(source) {
record('maps.setBaseImagery', [source]);
fake.maps.imagery = source;
lastBaseImagery = source;
},
async setTerrain(source) {
record('maps.setTerrain', [source]);
@@ -830,12 +834,23 @@ export function createFakeEngine(options = {}) {
},
async setPhotorealTiles(source) {
record('maps.setPhotorealTiles', [source]);
fake.maps.photoreal = source ?? null;
if (source == null) {
fake.maps.photoreal = null;
fake.maps.imagery = lastBaseImagery;
return;
}
fake.maps.photoreal = source;
fake.maps.imagery = null;
},
releasePhotorealTiles(source) {
record('maps.releasePhotorealTiles', [source]);
if (fake.maps.photoreal === source) fake.maps.photoreal = null;
},
photorealActive: () => fake.maps.photoreal != null,
current: () => ({
baseImagery: fake.maps.imagery ?? null,
photoreal: fake.maps.photoreal,
}),
},
destroy() {
record('destroy', []);