fix(import): address review on metadata, bounds, navigation, clock and GPX

- Imported metadata stays inert: descriptions, native labels, properties and
  KML extended data are dropped from every entity; names are kept as short
  plain text. GeoJSON loads with a describe that builds no HTML, and
  simplestyle (including marker-symbol) is dropped, so no icon asset is
  fetched for a file and GeoJSON draws in its row's color.
- Bound geometry before loading: features and vertices are counted in the
  parsed GeoJSON, the KML document and while reading GPX, and a file over
  20,000 features or 250,000 vertices is refused before Cesium builds it.
- Imports run one batch at a time, so concurrent picks or drops cannot pass
  the 12-file cap.
- Fly-to framing walks long tracks by index instead of spreading them into a
  call.
- Fly-to is an immediate navigation through the application shell; the fly
  after an import is a deferred navigation stamped when the batch starts and
  re-asserted just before flying.
- Dated imports never change the shared clock: imported sources expose no
  clock, entities are available for all time, and a KML gx:Track is drawn as
  its line.
- GPX is read into GeoJSON with one line per track segment, without times.
- KML/KMZ rows show a hatched swatch (they keep their own styles); keyboard
  focus is kept on the same control after hide/show and moves to the next
  row after remove.
- Unit regressions for each, and the rendered QA now imports hostile
  metadata, dated KML/GPX and a two-segment track, and fails on any request
  for a file's contents or icon assets, a clock change, or surviving
  metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
kapturnerandClaude Opus 5.5 committed 2026-10-07 08:50:35 -05:00
1 parent 2fec1091d4
commit 644a341b4c
11 files changed
+1199 -75

No files matched your search

+289
View File
@@ -1,8 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deflateRawSync } from 'node:zlib';
import * as Cesium from 'cesium';
import {
GeoImportError,
MAX_IMPORT_POSITIONS,
countGeoJsonGeometry,
countKmlGeometry,
detachImportedTime,
gpxToGeoJson,
importFeatureName,
scrubImportedEntities,
stripGeoJsonStyleProperties,
detectGeoFileFormat,
importDisplayName,
kmzImageType,
@@ -213,3 +222,283 @@ test('KMZ hrefs resolve only to entries inside the archive', () => {
assert.equal(kmzImageType('files/a.PNG'), 'image/png');
assert.equal(kmzImageType('files/a.svg'), null);
});
/* ── a tiny element tree with the DOM surface the readers use ─────────── */
function xmlEl(localName, attrs = {}, kids = []) {
const el = {
nodeType: 1,
localName,
childNodes: [],
getAttribute: (n) => (n in attrs ? String(attrs[n]) : null),
};
for (const k of typeof kids === 'string' ? [] : kids) el.childNodes.push(k);
const text = typeof kids === 'string' ? kids : null;
Object.defineProperty(el, 'textContent', {
get: () => text ?? el.childNodes.map((c) => c.textContent).join(''),
});
return el;
}
const xmlDoc = (root) => ({
documentElement: root,
getElementsByTagName(name) {
assert.equal(name, '*');
const out = [];
const walk = (el) => {
out.push(el);
el.childNodes.forEach(walk);
};
walk(root);
return out;
},
});
const trkpt = (lat, lon, time) =>
xmlEl('trkpt', { lat, lon }, time ? [xmlEl('time', {}, time)] : []);
test('GPX keeps each track segment as its own line, names as text, no times', () => {
const doc = xmlDoc(
xmlEl('gpx', {}, [
xmlEl('wpt', { lat: 37.77, lon: -122.49 }, [xmlEl('name', {}, 'Camp')]),
xmlEl('wpt', { lat: 99, lon: 0 }), // out of range: skipped
xmlEl('rte', {}, [
xmlEl('name', {}, 'Route'),
xmlEl('rtept', { lat: 1, lon: 2 }),
xmlEl('rtept', { lat: 3, lon: 4 }),
]),
xmlEl('trk', {}, [
xmlEl('name', {}, 'Morning walk'),
xmlEl('trkseg', {}, [
trkpt(10, 20, '2024-05-01T10:00:00Z'),
trkpt(10.1, 20.1, '2024-05-01T10:01:00Z'),
]),
// A pause in the recording: a new segment, not a straight jump.
xmlEl('trkseg', {}, [
trkpt(11, 21, '2024-05-01T11:00:00Z'),
trkpt(11.1, 21.1, '2024-05-01T11:01:00Z'),
trkpt(11.2, 21.2, '2024-05-01T11:02:00Z'),
]),
xmlEl('trkseg', {}, [trkpt(12, 22)]), // one point: not a line
]),
]),
);
const fc = gpxToGeoJson(doc);
assert.deepEqual(
fc.features.map((f) => [f.properties.name, f.geometry.type]),
[
['Camp', 'Point'],
['Route', 'LineString'],
['Morning walk', 'MultiLineString'],
],
);
assert.deepEqual(fc.features[2].geometry.coordinates, [
[
[20, 10],
[20.1, 10.1],
],
[
[21, 11],
[21.1, 11.1],
[21.2, 11.2],
],
]);
assert.doesNotMatch(JSON.stringify(fc), /2024|time/);
assert.throws(() => gpxToGeoJson(xmlDoc(xmlEl('kml'))), GeoImportError);
});
test('GPX past the point limit is refused while reading, before Cesium', () => {
const seg = xmlEl(
'trkseg',
{},
Array.from({ length: 30 }, (_, i) => trkpt(1 + i / 100, 2)),
);
const doc = xmlDoc(xmlEl('gpx', {}, [xmlEl('trk', {}, [seg])]));
assert.throws(
() => gpxToGeoJson(doc, { maxPositions: 20 }),
(e) => e instanceof GeoImportError && /more than .* points/.test(e.message),
);
assert.throws(
() =>
gpxToGeoJson(
xmlDoc(
xmlEl('gpx', {}, [
xmlEl('wpt', { lat: 1, lon: 1 }),
xmlEl('wpt', { lat: 2, lon: 2 }),
]),
),
{ maxFeatures: 1 },
),
/more than .* features/,
);
});
test('GeoJSON features and vertices are counted before loading, without recursion', () => {
const line = Array.from({ length: 50 }, (_, i) => [i / 10, 1]);
const fc = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', geometry: { type: 'LineString', coordinates: line } },
{
type: 'Feature',
geometry: {
type: 'GeometryCollection',
geometries: [
{ type: 'Point', coordinates: [1, 2] },
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
],
},
},
{ type: 'Feature', geometry: null },
],
};
assert.deepEqual(countGeoJsonGeometry(fc), {
features: 3,
positions: 55,
over: '',
});
assert.equal(
countGeoJsonGeometry(fc, { maxPositions: 54 }).over,
'positions',
);
assert.equal(countGeoJsonGeometry(fc, { maxFeatures: 2 }).over, 'features');
assert.deepEqual(
countGeoJsonGeometry({ type: 'Point', coordinates: [1, 2] }),
{
features: 1,
positions: 1,
over: '',
},
);
// A very long line is counted iteratively and stops at the limit.
const huge = {
type: 'LineString',
coordinates: Array.from({ length: MAX_IMPORT_POSITIONS + 5 }, () => [0, 0]),
};
assert.equal(countGeoJsonGeometry(huge).over, 'positions');
});
test('KML placemarks and coordinate tuples are counted before loading', () => {
const coords = (n) =>
xmlEl(
'coordinates',
{},
Array.from({ length: n }, (_, i) => `${i},1,0`).join('\n '),
);
const doc = xmlDoc(
xmlEl('kml', {}, [
xmlEl('Placemark', {}, [xmlEl('LineString', {}, [coords(40)])]),
xmlEl('Placemark', {}, [
xmlEl('Track', {}, [
xmlEl('coord', {}, '1 2 0'),
xmlEl('coord', {}, '1 3 0'),
]),
]),
]),
);
assert.deepEqual(countKmlGeometry(doc), {
features: 2,
positions: 42,
over: '',
});
assert.equal(countKmlGeometry(doc, { maxPositions: 41 }).over, 'positions');
assert.equal(countKmlGeometry(doc, { maxFeatures: 1 }).over, 'features');
});
test('simplestyle is dropped: no icon fetched, the row color is what draws', () => {
const fc = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
'marker-symbol': 'rail',
'marker-color': '#f00',
stroke: '#0f0',
'stroke-width': 9,
fill: '#00f',
'fill-opacity': 1,
name: 'x',
},
geometry: null,
},
{ type: 'Feature', properties: null, geometry: null },
],
};
stripGeoJsonStyleProperties(fc);
assert.deepEqual(fc.features[0].properties, { name: 'x' });
});
test('imported entities keep only a short plain-text name: no description, label or properties', () => {
const ds = new Cesium.CustomDataSource('t');
const e = ds.entities.add({
name: 'Trail\u0007 <b>head</b>\n' + 'x'.repeat(300),
description: '<img src="https://example.invalid/x.png" onerror="alert(1)">',
label: { text: 'Trail head' },
properties: { note: '<script>alert(1)</script>' },
position: Cesium.Cartesian3.fromDegrees(1, 2),
});
e.kml = { extendedData: { note: { value: '<iframe src=//x>' } } };
ds.entities.add({ name: 42, position: Cesium.Cartesian3.fromDegrees(1, 2) });
assert.equal(scrubImportedEntities(ds), 2);
assert.equal(e.description, undefined);
assert.equal(e.label, undefined);
assert.equal(e.properties, undefined);
assert.equal(e.kml, undefined);
assert.ok(e.name.startsWith('Trail <b>head</b> x'));
assert.ok(e.name.length <= 120 && !/[\u0000-\u001f]/.test(e.name));
assert.equal(ds.entities.values[1].name, undefined);
assert.equal(importFeatureName(' '), undefined);
});
test('a dated import exposes no clock and draws for all time', () => {
// A data source with its own clock, as KmlDataSource has for a dated file.
class DatedSource extends Cesium.CustomDataSource {
get clock() {
return this._testClock;
}
}
const ds = new DatedSource('dated');
ds._testClock = new Cesium.DataSourceClock();
const start = Cesium.JulianDate.fromIso8601('2020-01-01T00:00:00Z');
const stop = Cesium.JulianDate.fromIso8601('2020-01-01T01:00:00Z');
const span = new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop }),
]);
const track = new Cesium.SampledPositionProperty();
track.addSample(start, Cesium.Cartesian3.fromDegrees(10, 10));
track.addSample(stop, Cesium.Cartesian3.fromDegrees(10.1, 10.1));
const mover = ds.entities.add({
availability: span,
position: track,
path: {},
billboard: {},
});
const pin = ds.entities.add({
availability: span,
position: Cesium.Cartesian3.fromDegrees(1, 1),
});
assert.ok(ds.clock);
detachImportedTime(ds);
assert.equal(ds.clock, undefined);
// The viewer reads `clock` from every added source; setting it is ignored.
ds.clock = new Cesium.DataSourceClock();
assert.equal(ds.clock, undefined);
assert.equal(pin.availability, undefined);
assert.ok(pin.isAvailable(Cesium.JulianDate.now()));
// The moving marker became the line it travels, visible now.
assert.equal(mover.availability, undefined);
assert.equal(mover.position, undefined);
assert.equal(mover.path, undefined);
assert.equal(mover.billboard, undefined);
const line = mover.polyline.positions.getValue(Cesium.JulianDate.now());
assert.ok(line.length > 100);
assert.ok(mover.polyline.clampToGround.getValue());
});