fix(import): enforce geometry budgets and queued navigation

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-14 01:02:50 +02:00
1 parent 7694c02b91
commit a5b21b5d0f
7 files changed
+238 -30

No files matched your search

+3 -1
View File
@@ -170,7 +170,9 @@
names from the file (no descriptions, balloons or native labels), never
moves the app's clock (dated tracks draw for all time), and keeps GPX track
segments apart. Size limits are checked before anything is built. The import
half of #890.
half of #890. Thanks to Chase Mills (@kapturner). Multipart geometry limits
apply before loading, recorded KML tracks retain their original vertices,
and queued imports respect navigation made while waiting.
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
+4 -4
View File
@@ -4325,13 +4325,13 @@ are omitted rather than framing the wrong part of the globe.
### Import Your Own Files (October 2026)
- DISPLAY ▸ **Import** puts GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX files on the globe. Files come in through the Import button (a hidden `<input type=file multiple>`) or by dropping them onto the globe; the format is taken from the extension, then from the first bytes. Each file becomes its own Cesium data source, clamped to the ground, with a row to show/hide it, fly to it, or remove it. GeoJSON and GPX are drawn in the row's color from the whiteboard palette (simplestyle properties in GeoJSON are dropped); KML and KMZ keep the styles written in them, and their row shows a hatched swatch instead of a color. GPX is read by `gpxToGeoJson` and loaded as GeoJSON: waypoints are points, each route a line, and each track one `MultiLineString` with a line per `<trkseg>`, so a pause in a recording stays a gap. Imported files last for the page session: a reload starts empty, and nothing is uploaded or stored.
- **Navigation.** The row's Fly-to is an immediate navigation through the application shell (`runImmediateNavigation('file', …)`): Cockpit refuses it, and the current follow/track owner is released first. The fly after an import is a deferred navigation: `beginDeferredLocationNavigation()` stamps it when the batch starts and `reassertDeferredLocationNavigation()` re-checks it just before flying, so a camera gesture, search, Cockpit or any newer navigation while files load wins and the import does not fly. Fly-to frames the features' longitudes and latitudes at the surface (thinned to 5,000 points by index, never spread into a call) and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`); `viewer.flyTo` is not used, because before terrain streams in it frames ground-clamped points kilometers below the surface.
- **Clock.** An import never changes the app's shared clock. The viewer adopts the clock of every data source added to it, so each imported data source reports no clock (`detachImportedTime`); every entity is available for all time, and a time-dynamic KML position (`gx:Track`) is drawn as the line it travels. GPX times are not read at all.
- **Navigation.** The row's Fly-to is an immediate navigation through the application shell (`runImmediateNavigation('file', …)`): Cockpit refuses it, and the current follow/track owner is released first. The fly after an import is a deferred navigation: `beginDeferredLocationNavigation()` stamps it when the pick or drop is submitted, before queued batches wait and `reassertDeferredLocationNavigation()` re-checks it just before flying, so a camera gesture, search, Cockpit or any newer navigation while files load wins and the import does not fly. Fly-to frames the features' longitudes and latitudes at the surface (thinned to 5,000 points by index, never spread into a call) and lets the camera ground guard lift the eye once the terrain has loaded (`src/ui/flyToDataSource.js`); `viewer.flyTo` is not used, because before terrain streams in it frames ground-clamped points kilometers below the surface.
- **Clock.** An import never changes the app's shared clock. The viewer adopts the clock of every data source added to it, so each imported data source reports no clock (`detachImportedTime`); every entity is available for all time, and KML `gx:Track` / `gx:MultiTrack` geometry is converted to static lines before Cesium loads it, preserving the recorded vertices and segment gaps without expanding them through time sampling. GPX times are not read at all.
- Runtime entry: `createApplicationTools` (`src/app/tools.js`) calls `initGeoFileImport({ viewer, navigation: styleManager })` (`src/ui/geoFileImport.js`) after the draw tool and defers its `destroy()`, which removes every imported data source, revokes every blob URL, and gives back the drop listeners on the viewer container and the `window.__gevGeoImport` handle (`importFiles`, `list`, `setVisible`, `flyTo`, `remove`, `diagnostics`). Parsing lives in `src/data/geoFileImport.js`.
- **An imported file cannot make the page fetch anything.** Before `KmlDataSource` reads a KML/KMZ document, `sanitizeKmlDocument` removes `NetworkLink`, `NetworkLinkControl`, `ScreenOverlay`, `PhotoOverlay`, `Model` and `Tour`, every `href` that is not an image packed in the same KMZ or a `data:image/` URL (an icon then falls back to Cesium's default pin, drawn locally, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML. GeoJSON `marker-symbol` (a maki icon asset) is dropped. KMZ images are served from blob URLs, one per packed image however many hrefs name it (`kmzImageResolver`). The hint says when something was left out. `scripts/qa-geo-import.mjs` imports files that name a trap host in each of those places and fails on any request that leaves the app's own origin.
- **An imported file cannot make the page fetch anything.** Before `KmlDataSource` reads a KML/KMZ document, `sanitizeKmlDocument` removes `NetworkLink`, `NetworkLinkControl`, `ScreenOverlay`, `PhotoOverlay`, `Model` and `Tour`, every `href` that is not an image packed in the same KMZ or a `data:image/` URL (an icon then falls back to Cesium's default pin, drawn locally, and a `GroundOverlay` left without an image is dropped), every `styleUrl` that is not a local `#id`, and placemark `description`/`BalloonStyle`/`Snippet` HTML and `ExtendedData` before metadata processing. GeoJSON `marker-symbol` (a maki icon asset) is dropped. KMZ images are served from blob URLs, one per packed image however many hrefs name it (`kmzImageResolver`). The hint says when something was left out. `scripts/qa-geo-import.mjs` imports files that name a trap host in each of those places and fails on any request that leaves the app's own origin.
- **Imported metadata stays inert.** After loading, `scrubImportedEntities` removes every entity's description, native label, properties and KML extended data, and keeps the name only as plain text without control characters, at most 120 characters. GeoJSON is loaded with a `describe` that builds no HTML. The file name is shown with `textContent` only.
- **KMZ** is read by a small zip reader (`readZipEntries`: stored and deflated entries through the platform `DecompressionStream`; no encryption, no ZIP64). The main document is `doc.kml`, else the shallowest `.kml`; relative hrefs resolve only to entries inside the archive.
- **Bounds.** A file may be at most 50 MB (`MAX_IMPORT_BYTES`), a KMZ may inflate to at most 120 MB in total (`MAX_KMZ_INFLATED_BYTES`, the zip-bomb stop), one file may hold at most 20,000 features (`MAX_IMPORT_ENTITIES`) and 250,000 vertices (`MAX_IMPORT_POSITIONS`), and a KML/KMZ at most 2,000 `<href>` and `<Style>` elements (`MAX_KML_LINKS`), counted before Cesium builds anything (`countGeoJsonGeometry`, `countKmlGeometry`, and while reading GPX), and the entity count is checked again after loading. At most 12 files are on the globe at once (`MAX_IMPORTED_FILES`): imports run one batch at a time, so two picks or drops in quick succession cannot pass the cap; files over it are not read. A refused file names its reason in the hint and does not stop the rest of the batch; a file with nothing to draw is refused.
- **Bounds.** A file may be at most 50 MB (`MAX_IMPORT_BYTES`), a KMZ may inflate to at most 120 MB in total (`MAX_KMZ_INFLATED_BYTES`, the zip-bomb stop), one file may create at most 20,000 Cesium entities, including multipart geometries and KML parent containers (`MAX_IMPORT_ENTITIES`) and 250,000 vertices (`MAX_IMPORT_POSITIONS`), and a KML/KMZ at most 2,000 `<href>` and `<Style>` elements (`MAX_KML_LINKS`), counted before Cesium builds anything (`countGeoJsonGeometry`, `countKmlGeometry`, and while reading GPX), and the entity count is checked again after loading. At most 12 files are on the globe at once (`MAX_IMPORTED_FILES`): imports run one batch at a time, so two picks or drops in quick succession cannot pass the cap; files over it are not read. A refused file names its reason in the hint and does not stop the rest of the batch; a file with nothing to draw is refused.
- **Keyboard.** Hide, show and remove rebuild the list without losing focus: the same control on the same row keeps it, a removed row hands it to the next row (else the Import button).
- Gate: `node scripts/qa-geo-import.mjs` (dev server up). Unit: `src/data/geoFileImport.test.mjs`, `src/ui/geoFileImport.test.mjs`, `src/ui/flyToDataSource.test.mjs`.
+16 -1
View File
@@ -78,6 +78,10 @@ const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
<Placemark><name>QA moving</name>
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
<Placemark><name>QA split tracks</name><gx:MultiTrack>
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
</gx:MultiTrack></Placemark>
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
@@ -361,6 +365,11 @@ try {
movingIsLine: Boolean(
named('QA moving')?.polyline && !named('QA moving')?.position,
),
movingVertices:
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
splitTracks: sources[1].entities.values
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
.map((e) => e.polyline.positions.getValue(now).length),
gpxLines: gpx.filter((e) => e.polyline).length,
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
};
@@ -380,9 +389,15 @@ try {
inside.clocks === 0 &&
inside.hidden === 0 &&
inside.datedPinNow &&
inside.movingIsLine,
inside.movingIsLine &&
inside.movingVertices === 2,
JSON.stringify(inside),
);
check(
'KML MultiTrack keeps its segments and original vertex count',
inside.splitTracks.join() === '2,2',
JSON.stringify(inside.splitTracks),
);
check(
'the GPX track keeps its two segments apart',
inside.gpxLines === 2 && inside.gpxPoints === 1,
+91 -18
View File
@@ -154,18 +154,24 @@ export function parseGeoJsonText(text) {
* Count features and vertices in a parsed GeoJSON object without recursion
* limits or array spreading, stopping as soon as a limit is passed.
* @param {object} geojson
* @param {{maxFeatures?: number, maxPositions?: number}} [limits]
* @returns {{features: number, positions: number, over: ''|'features'|'positions'}}
* @param {{maxFeatures?: number, maxPositions?: number, maxEntities?: number}} [limits]
* @returns {{features: number, positions: number, entities: number,
* over: ''|'features'|'positions'|'entities'}}
*/
export function countGeoJsonGeometry(
geojson,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
maxEntities = MAX_IMPORT_ENTITIES,
} = {},
) {
let features = 0;
let features = ['Feature', 'FeatureCollection'].includes(geojson?.type)
? 0
: 1;
let positions = 0;
let entities = 0;
const result = (over = '') => ({ features, positions, entities, over });
const stack = [geojson];
while (stack.length) {
const node = stack.pop();
@@ -177,8 +183,11 @@ export function countGeoJsonGeometry(
}
if (node.type === 'Feature') {
features += 1;
if (features > maxFeatures)
return { features, positions, over: 'features' };
if (features > maxFeatures) return result('features');
if (node.geometry == null) {
entities += 1;
if (entities > maxEntities) return result('entities');
}
stack.push(node.geometry);
continue;
}
@@ -188,8 +197,14 @@ export function countGeoJsonGeometry(
continue;
}
if (GEOJSON_TYPES.has(node.type)) {
// A bare geometry is one feature: Cesium makes one entity for it.
if (node === geojson) features += 1;
// Multipart geometries create one Cesium entity per component, even
// when the document wraps them in just one Feature.
entities += node.type.startsWith('Multi')
? Array.isArray(node.coordinates)
? node.coordinates.length
: 0
: 1;
if (entities > maxEntities) return result('entities');
// Walk the coordinate arrays iteratively: a position is an array whose
// first item is a number.
const coords = [node.coordinates];
@@ -198,17 +213,20 @@ export function countGeoJsonGeometry(
if (!Array.isArray(c)) continue;
if (typeof c[0] === 'number') {
positions += 1;
if (positions > maxPositions)
return { features, positions, over: 'positions' };
if (positions > maxPositions) return result('positions');
} else for (let i = 0; i < c.length; i++) coords.push(c[i]);
}
}
}
return { features, positions, over: '' };
return result();
}
/** The refusal for a file over a size limit, in the person's terms. */
function overLimitError({ over, features, positions }) {
if (over === 'entities')
return new GeoImportError(
`That file draws more than ${MAX_IMPORT_ENTITIES.toLocaleString()} objects (the limit).`,
);
if (over === 'links')
return new GeoImportError(
`That file has more than ${MAX_KML_LINKS.toLocaleString()} styles and icon links (the limit).`,
@@ -485,8 +503,36 @@ export function sanitizeKmlDocument(doc, resolveLocal = () => null) {
}
// Placemark balloons are HTML; the app shows no info box, and keeping their
// <img>/<iframe> sources out means no future surface can load them either.
for (const name of ['description', 'BalloonStyle', 'Snippet'])
for (const name of ['description', 'BalloonStyle', 'Snippet', 'ExtendedData'])
elementsNamed(doc, name).forEach(drop);
// Draw recorded KML paths as their original static vertices. Letting the
// loader build time-dynamic tracks would expand each into thousands of
// interpolated points later and exceed the checked geometry budget.
for (const track of elementsNamed(doc, 'Track')) {
const line = doc.createElementNS(
'http://www.opengis.net/kml/2.2',
'LineString',
);
const coordinates = doc.createElementNS(
'http://www.opengis.net/kml/2.2',
'coordinates',
);
const recordedCoordinates = childrenNamed(track, 'coord').map((coord) =>
coord.textContent.trim().split(/\s+/).join(','),
);
coordinates.textContent = recordedCoordinates.join(' ');
line.appendChild(coordinates);
track.parentNode?.replaceChild(line, track);
}
for (const tracks of elementsNamed(doc, 'MultiTrack')) {
const geometry = doc.createElementNS(
'http://www.opengis.net/kml/2.2',
'MultiGeometry',
);
for (const line of childrenNamed(tracks, 'LineString'))
geometry.appendChild(line);
tracks.parentNode?.replaceChild(geometry, tracks);
}
return { removed };
}
@@ -508,23 +554,46 @@ const childText = (el, localName) =>
* and `<Style>` elements (`links`), each of which can make Cesium load and
* decode an image.
* @param {Document} doc
* @returns {{features: number, positions: number, links: number,
* over: ''|'features'|'positions'|'links'}}
* @returns {{features: number, entities: number, positions: number, links: number,
* over: ''|'features'|'entities'|'positions'|'links'}}
*/
export function countKmlGeometry(
doc,
{
maxFeatures = MAX_IMPORT_ENTITIES,
maxEntities = MAX_IMPORT_ENTITIES,
maxPositions = MAX_IMPORT_POSITIONS,
maxLinks = MAX_KML_LINKS,
} = {},
) {
let features = 0;
let entities = 0;
let positions = 0;
let links = 0;
const result = (over) => ({ features, positions, links, over });
const result = (over) => ({ features, entities, positions, links, over });
const geometryNames = new Set([
'Point',
'LineString',
'LinearRing',
'Polygon',
'Track',
'MultiTrack',
'MultiGeometry',
'Model',
]);
for (const el of doc.getElementsByTagName('*')) {
const name = el.localName;
// Cesium creates a parent for every placemark and a child entity for
// each geometry in a MultiGeometry, including nested containers.
if (
name === 'Placemark' ||
name === 'GroundOverlay' ||
(geometryNames.has(name) &&
['MultiGeometry', 'MultiTrack'].includes(el.parentNode?.localName))
) {
entities += 1;
if (entities > maxEntities) return result('entities');
}
if (name === 'href' || name === 'Style') {
links += 1;
if (links > maxLinks) return result('links');
@@ -574,8 +643,12 @@ export function gpxToGeoJson(
const features = [];
let positions = 0;
const point = (el) => {
const lat = Number(el.getAttribute('lat'));
const lon = Number(el.getAttribute('lon'));
const rawLat = el.getAttribute('lat');
const rawLon = el.getAttribute('lon');
if (rawLat == null || rawLon == null || !rawLat.trim() || !rawLon.trim())
return null;
const lat = Number(rawLat);
const lon = Number(rawLon);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
@@ -789,10 +862,10 @@ export async function loadGeoFile(file, { viewer, color = '#39d0ff' }) {
);
} else {
geojson = parseGeoJsonText(new TextDecoder().decode(bytes));
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
stripGeoJsonStyleProperties(geojson);
}
const size = countGeoJsonGeometry(geojson);
if (size.over) throw overLimitError(size);
dataSource = await Cesium.GeoJsonDataSource.load(geojson, {
clampToGround: true,
stroke,
+82 -1
View File
@@ -5,12 +5,14 @@ import * as Cesium from 'cesium';
import {
GeoImportError,
MAX_IMPORT_POSITIONS,
MAX_IMPORT_ENTITIES,
countGeoJsonGeometry,
countKmlGeometry,
detachImportedTime,
gpxToGeoJson,
importFeatureName,
scrubImportedEntities,
sanitizeKmlDocument,
stripGeoJsonStyleProperties,
detectGeoFileFormat,
importDisplayName,
@@ -231,8 +233,15 @@ function xmlEl(localName, attrs = {}, kids = []) {
localName,
childNodes: [],
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
removeChild(child) {
this.childNodes.splice(this.childNodes.indexOf(child), 1);
child.parentNode = null;
},
};
for (const k of typeof kids === 'string' ? [] : kids) el.childNodes.push(k);
for (const k of typeof kids === 'string' ? [] : kids) {
k.parentNode = el;
el.childNodes.push(k);
}
const text = typeof kids === 'string' ? kids : null;
Object.defineProperty(el, 'textContent', {
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
@@ -252,6 +261,17 @@ const xmlDoc = (root) => ({
return out;
},
});
test('KML extended metadata is removed before the document reaches the loader', () => {
const point = xmlEl('Point', {}, [xmlEl('coordinates', {}, '1,2,0')]);
const metadata = xmlEl('ExtendedData', {}, [
xmlEl('Data', { name: 'note' }, [xmlEl('value', {}, 'ordinary metadata')]),
]);
const placemark = xmlEl('Placemark', {}, [metadata, point]);
const doc = xmlDoc(xmlEl('kml', {}, [placemark]));
assert.equal(sanitizeKmlDocument(doc).removed, 1);
assert.deepEqual(placemark.childNodes, [point]);
assert.equal(metadata.parentNode, null);
});
const trkpt = (lat, lon, time) =>
xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []);
@@ -363,6 +383,7 @@ test('GeoJSON features and vertices are counted before loading, without recursio
assert.deepEqual(countGeoJsonGeometry(fc), {
features: 3,
positions: 55,
entities: 4,
over: '',
});
assert.equal(
@@ -375,6 +396,7 @@ test('GeoJSON features and vertices are counted before loading, without recursio
{
features: 1,
positions: 1,
entities: 1,
over: '',
},
);
@@ -386,6 +408,35 @@ test('GeoJSON features and vertices are counted before loading, without recursio
assert.equal(countGeoJsonGeometry(huge).over, 'positions');
});
test('multipart GeoJSON is refused before loading when it expands past the entity cap', () => {
const parts = Array.from({ length: MAX_IMPORT_ENTITIES + 1 }, () => [1, 2]);
for (const geometry of [
{ type: 'MultiPoint', coordinates: parts },
{
type: 'GeometryCollection',
geometries: parts.map((coordinates) => ({ type: 'Point', coordinates })),
},
{ type: 'MultiLineString', coordinates: parts.map((p) => [p, [2, 3]]) },
]) {
const size = countGeoJsonGeometry({ type: 'Feature', geometry });
assert.equal(size.over, 'entities');
}
});
test('GPX points with missing or blank coordinates are skipped rather than placed at zero', () => {
const doc = xmlDoc(
xmlEl('gpx', {}, [
xmlEl('wpt', { lon: 2 }),
xmlEl('wpt', { lat: 1 }),
xmlEl('wpt', { lat: '', lon: 2 }),
xmlEl('wpt', { lat: 1, lon: ' ' }),
xmlEl('wpt', { lat: 0, lon: 0 }),
]),
);
assert.equal(gpxToGeoJson(doc).features.length, 1);
assert.deepEqual(gpxToGeoJson(doc).features[0].geometry.coordinates, [0, 0]);
});
test('KML placemarks and coordinate tuples are counted before loading', () => {
const coords = (n) =>
xmlEl(
@@ -406,6 +457,7 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
);
assert.deepEqual(countKmlGeometry(doc), {
features: 2,
entities: 2,
positions: 42,
links: 0,
over: '',
@@ -414,6 +466,35 @@ test('KML placemarks and coordinate tuples are counted before loading', () => {
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
});
test('KML multipart geometries count every Cesium entity before loading', () => {
const doc = xmlDoc(
xmlEl('kml', {}, [
xmlEl('Placemark', {}, [
xmlEl('MultiGeometry', {}, [
xmlEl('Point'),
xmlEl('MultiGeometry', {}, [xmlEl('Point'), xmlEl('LineString')]),
xmlEl('MultiTrack', {}, [xmlEl('Track'), xmlEl('Track')]),
]),
]),
xmlEl('GroundOverlay'),
]),
);
assert.equal(countKmlGeometry(doc).entities, 9);
assert.equal(countKmlGeometry(doc, { maxEntities: 8 }).over, 'entities');
const oversized = xmlDoc(
xmlEl('kml', {}, [
xmlEl('Placemark', {}, [
xmlEl(
'MultiGeometry',
{},
Array.from({ length: MAX_IMPORT_ENTITIES }, () => xmlEl('Point')),
),
]),
]),
);
assert.equal(countKmlGeometry(oversized).over, 'entities');
});
test('KML styles and icon links are counted against their own limit', () => {
const style = () =>
xmlEl('Style', {}, [
+6 -5
View File
@@ -212,17 +212,18 @@ export function initGeoFileImport({
*/
function importFiles(files) {
const list = [...(files || [])];
const run = queue.then(() => importBatch(list));
if (destroyed || !list.length) return Promise.resolve([]);
// Capture the user's intent when the files are chosen, including time
// spent waiting for an earlier batch. Later navigation must still win.
const generation = beginAutoFly();
const run = queue.then(() => importBatch(list, generation));
queue = run.catch(() => {});
return run;
}
async function importBatch(list) {
async function importBatch(list, generation) {
const outcomes = [];
if (destroyed || !list.length) return outcomes;
// Stamped now, re-asserted before the fly: whatever navigation happens
// while the files load (a drag, a search, Cockpit) supersedes this one.
const generation = beginAutoFly();
for (const file of list) {
if (destroyed) break;
const fileName = String(file?.name || 'file');
+36
View File
@@ -529,6 +529,42 @@ test('a slow import does not fly once something newer took the camera', async ()
assert.equal(flown.length, 0);
});
test('a queued import cannot fly over navigation that happened while it waited', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let generation = 0;
const flown = [];
const nav = {
beginDeferredLocationNavigation: () => ++generation,
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
};
const t = setup({
navigation: nav,
flyToSource: (v, ds) => flown.push(ds),
load: async (f) => {
if (f.name === 'first.geojson') await gate;
return {
dataSource: {},
name: f.name,
format: 'geojson',
entityCount: 1,
revoke() {},
};
},
});
const first = t.tool.importFiles([file('first.geojson')]);
await new Promise((resolve) => setImmediate(resolve));
const queued = t.tool.importFiles([file('queued.geojson')]);
generation++; // A newer user camera action while the second batch is waiting.
release();
const results = await Promise.all([first, queued]);
assert.ok(results.every(([outcome]) => outcome.ok));
assert.equal(t.tool.list().length, 2);
assert.equal(flown.length, 0);
});
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
const flown = [];
const nav = navigationDouble();