Merge upstream main into pr/panoramax

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-15 16:56:15 +01:00
commit b5de533567
40 files changed
+6370 -70

No files matched your search

+8 -1
View File
@@ -30,6 +30,8 @@
"src/annotations/drawMode.test.mjs",
"src/annotations/drawTool.js",
"src/annotations/drawTool.test.mjs",
"src/data/geoFileImport.test.mjs",
"src/ui/geoFileImport.test.mjs",
"src/annotations/adminOutline.test.mjs",
"src/annotations/regionRing.test.mjs",
"src/data/adminBoundaries.test.mjs",
@@ -203,6 +205,9 @@
"src/styles/snow.js",
"scripts/qa-visual-effects.mjs",
"scripts/qa-draw-tool.mjs",
"scripts/qa-geo-import.mjs",
"scripts/qa-geo-import-regressions.mjs",
"scripts/qa-geo-import-large.mjs",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs",
@@ -1034,5 +1039,7 @@
"src/tools/globePanel.test.mjs",
"src/costEndpointThrottle.test.mjs",
"src/sources/gbfsStations.test.mjs",
"scripts/qa-gbfs-names.mjs"
"scripts/qa-gbfs-names.mjs",
"src/ui/flyToDataSource.js",
"src/ui/flyToDataSource.test.mjs"
]
+11 -3
View File
@@ -2027,6 +2027,9 @@
"src/data/inputOwnership.js",
"src/annotations/drawMode.js",
"src/annotations/drawTool.js",
"src/data/geoFileImport.js",
"src/ui/flyToDataSource.js",
"src/ui/geoFileImport.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/layers/directions/source.js",
@@ -2251,7 +2254,10 @@
"src/tools/functions.js",
"src/voice/actionSchemas.js",
"src/view/index.js",
"src/app/embed.js"
"src/app/embed.js",
"src/data/cameraFraming.js",
"src/data/geoImportPins.js",
"src/data/geoImportImages.js"
],
"external": [
"hls.js",
@@ -2800,7 +2806,8 @@
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/sources/infrastructureData.js",
"src/data/cameraFraming.js"
],
"external": [
"hls.js",
@@ -2865,7 +2872,8 @@
"src/sources/overpassFeatures.js",
"src/sources/featureSource.js",
"src/sources/capability.js",
"src/data/cameraArrival.js"
"src/data/cameraArrival.js",
"src/data/cameraFraming.js"
],
"external": [
"cesium",
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env node
/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
* QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
* For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
const supplied = process.argv.includes('--file')
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
: null;
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
const file = path.join(directory, 'metadata-rich-27mb.kml');
const style =
'<Style><IconStyle><scale>0.6</scale><color>ff00dd66</color></IconStyle></Style>';
const notes = 'A local observation with supporting detail. '.repeat(120);
const placemarks = Array.from({ length: 5000 }, (_, i) => {
const lon = -122.6 + (i % 100) * 0.002;
const lat = 37.65 + Math.floor(i / 100) * 0.003;
return `<Placemark><name>Point ${i}</name><styleUrl>#base</styleUrl>${style}<description>${notes}</description><Point><coordinates>${lon},${lat},0</coordinates></Point></Placemark>`;
});
const wrap = (body) =>
`<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Style id="base"><IconStyle><heading>25</heading><scale>1.2</scale></IconStyle><LineStyle><color>ffdd6600</color><width>7</width></LineStyle><PolyStyle><color>6600ddff</color><fill>1</fill><outline>0</outline></PolyStyle></Style>${body}</Document></kml>`;
const large = wrap(placemarks.join(''));
fs.writeFileSync(file, large);
const bytes = fs.statSync(file).size;
if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
const small = wrap(
Array.from({ length: 6 }, (_, i) => {
const kind = i % 3;
const geometry =
kind === 0
? '<Point><coordinates>-122.49,37.77,0</coordinates></Point>'
: kind === 1
? '<LineString><coordinates>-122.50,37.77,0 -122.49,37.77,0</coordinates></LineString>'
: '<Polygon><outerBoundaryIs><LinearRing><coordinates>-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0</coordinates></LinearRing></outerBoundaryIs></Polygon>';
const override =
kind === 0
? style
: kind === 1
? '<Style><LineStyle><width>3</width></LineStyle></Style>'
: '<Style><PolyStyle><outline>1</outline></PolyStyle></Style>';
return `<Placemark><name>Small ${i}</name><styleUrl>#base</styleUrl>${override}${geometry}</Placemark>`;
}).join('') +
'<Style id="named-a"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<Style id="named-b"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
'<StyleMap id="paired"><Pair><key>normal</key><Style><IconStyle><scale>0.8</scale></IconStyle></Style></Pair></StyleMap>' +
['named-a', 'named-b', 'paired']
.map(
(id) =>
`<Placemark><name>${id}</name><styleUrl>#${id}</styleUrl><Point><coordinates>-122.49,37.77,0</coordinates></Point></Placemark>`,
)
.join(''),
);
const browser = await puppeteer.launch({
headless: true,
args:
process.platform === 'darwin'
? ['--use-gl=angle', '--use-angle=metal']
: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const fileRequests = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
fileRequests.push(request.url());
void request.abort();
} else void request.continue();
});
let failures = 0;
const check = (name, ok, detail) => {
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
if (!ok) failures++;
};
try {
await page.setViewport({ width: 1440, height: 900 });
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView;
const C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.clock.shouldAnimate = false;
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
window.__qaStyleSnapshot = (source) => {
const now = viewer.clock.currentTime;
const images = new Map();
const imageValue = (p) => {
const value = p?.getValue(now);
if (value?.tagName !== 'CANVAS') return value;
if (!images.has(value)) images.set(value, value.toDataURL());
return images.get(value);
};
const color = (p) => p?.getValue(now)?.toCssColorString();
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
const renderedPins = new Map();
const collection = source.importedBillboards;
if (collection)
for (let i = 0; i < collection.length; i++) {
const pin = collection.get(i);
renderedPins.set(pin.id, pin);
}
const actual = (e, key, fallback) => {
const pin = renderedPins.get(e);
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
};
return (source.importedEntities || source.entities.values)
.map((e) => ({
name: e.name,
point: renderedPins.get(e)?.position || e.position?.getValue(now),
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
color: e.billboard
? actual(e, 'color', C.Color.WHITE).toCssColorString()
: undefined,
width: e.polyline?.width?.getValue(now),
lineColor: color(e.polyline?.material?.color),
line: positions(e.polyline?.positions),
fill: e.polygon?.fill?.getValue(now),
outline: e.polygon?.outline?.getValue(now),
polygonColor: color(e.polygon?.material?.color),
polygon: e.polygon?.hierarchy
?.getValue(now)
?.positions?.map((v) => [v.x, v.y, v.z]),
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
window.__qaUnsharedKml = async (text) => {
const doc = new DOMParser().parseFromString(text, 'application/xml');
for (const el of [...doc.getElementsByTagName('*')])
if (
['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
)
el.remove();
const source = await C.KmlDataSource.load(doc, {
camera: viewer.scene.camera,
canvas: viewer.scene.canvas,
clampToGround: true,
});
for (const e of source.importedEntities || source.entities.values)
e.label = undefined;
return source;
};
});
const equivalent = await page.evaluate(async (text) => {
const { viewer } = window.__godsEyeView;
const control = await window.__qaUnsharedKml(text);
const outcomes = await window.__gevGeoImport.importFiles([
new File([text], 'style-equivalence.kml'),
]);
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
const result = {
ok: outcomes[0]?.ok,
same:
JSON.stringify(window.__qaStyleSnapshot(control)) ===
JSON.stringify(window.__qaStyleSnapshot(candidate)),
entities: (candidate.importedEntities || candidate.entities.values)
.length,
};
await window.__gevGeoImport.remove(outcomes[0]?.id);
return result;
}, small);
check(
'shared inline and named styles preserve point, line and polygon overrides/inheritance',
equivalent.ok && equivalent.same && equivalent.entities === 9,
JSON.stringify(equivalent),
);
// The control bypasses the former style-count refusal solely in this test,
// to compare native unshared rendering against normalized rendering.
await page.evaluate(async (text) => {
const source = await window.__qaUnsharedKml(text);
window.__qaUnsharedSnapshot = JSON.stringify(
window.__qaStyleSnapshot(source),
);
window.__qaControl = source;
await window.__godsEyeView.viewer.dataSources.add(source);
}, large);
const measure = async () =>
page.evaluate(async () => {
const C = window.__CESIUM__,
{ viewer } = window.__godsEyeView;
const scene = viewer.scene;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const started = performance.now(),
samples = [];
let last,
frames = 0;
const remove = scene.postRender.addEventListener(() => {
const t = performance.now();
if (t - started < 2000) return;
frames++;
if (last !== undefined) samples.push(t - last);
last = t;
});
await new Promise((resolve) => {
let previous = performance.now();
function tick(t) {
viewer.camera.rotate(
C.Cartesian3.UNIT_Z,
Math.min(t - previous, 100) * 0.0000002,
);
previous = t;
scene.requestRender();
if (t - started < 7000) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
remove();
samples.sort((a, b) => a - b);
const gl = scene.context._gl,
debug = gl.getExtension('WEBGL_debug_renderer_info');
return {
fps: frames / 5,
p50Ms: samples[Math.floor(samples.length * 0.5)],
p95Ms: samples[Math.floor(samples.length * 0.95)],
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
};
});
console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
await page.evaluate(() =>
window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
);
const loadPicked = async (filename, expected) => {
const before = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
const priorIds = await page.evaluate(() =>
window.__gevGeoImport.list().map((f) => f.id),
);
const started = performance.now();
await (await page.$('#geo-import-input')).uploadFile(filename);
const name = path.basename(filename);
const loaded = await page
.waitForFunction(
({ name, priorIds }) =>
window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
document
.getElementById('geo-import-hint')
.textContent.startsWith(`${name}:`),
{ timeout: 120000 },
{ name, priorIds },
)
.then(
() => true,
() => false,
);
const state = await page.evaluate(() => ({
files: window.__gevGeoImport.list(),
hint: document.getElementById('geo-import-hint').textContent,
sources: window.__godsEyeView.viewer.dataSources.length,
}));
const item = state.files.find((f) => !priorIds.includes(f.id));
check(
`picked ${name} loads`,
loaded &&
(expected === null
? item?.entityCount > 0
: item?.entityCount === expected) &&
state.sources === before + 1,
JSON.stringify({
bytes: fs.statSync(filename).size,
loadMs: Math.round(performance.now() - started),
...state,
}),
);
return item;
};
const item = await loadPicked(file, 5000);
if (item) {
const same = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
return (
JSON.stringify(window.__qaStyleSnapshot(source)) ===
window.__qaUnsharedSnapshot
);
});
check('all 5,000 normalized graphics match their unshared control', same);
console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
}
if (supplied) {
const clockBefore = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
const suppliedItem = await loadPicked(supplied, null);
const clockAfter = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__;
return [
C.JulianDate.toIso8601(v.clock.startTime),
C.JulianDate.toIso8601(v.clock.stopTime),
C.JulianDate.toIso8601(v.clock.currentTime),
v.clock.multiplier,
];
});
check(
'real dated KML leaves the shared clock unchanged',
JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
);
if (suppliedItem && process.argv.includes('--expect-local-pins')) {
const markers = await page.evaluate(() => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__;
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
const points = (
source.importedEntities || source.entities.values
).filter((e) => e.position && e.billboard);
const images = points.filter((e) =>
e.billboard.image?.getValue(viewer.clock.currentTime),
);
if (images.length) {
const at = C.Cartographic.fromCartesian(
images[0].position.getValue(viewer.clock.currentTime),
);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromRadians(
at.longitude,
at.latitude,
100000,
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
return { points: points.length, images: images.length };
});
check(
'supplied point markers have drawable images',
markers.points > 0 && markers.images === markers.points,
JSON.stringify(markers),
);
if (markers.points) {
const yellowPixels = async () => {
const shot = await page.screenshot({
clip: { x: 620, y: 350, width: 200, height: 200 },
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
).data;
let count = 0;
for (let i = 0; i < data.length; i += 4)
if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
count++;
return count;
}, shot);
};
const setVisible = (visible) =>
page.evaluate(
({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
{ id: suppliedItem.id, visible },
);
await setVisible(false);
await new Promise((resolve) => setTimeout(resolve, 1000));
const hidden = await yellowPixels();
await setVisible(true);
let shown = hidden;
const deadline = performance.now() + 10000;
while (performance.now() < deadline && shown <= hidden + 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
shown = await yellowPixels();
}
check(
'real USGS markers paint when shown and disappear when hidden',
shown > hidden + 10,
JSON.stringify({ hidden, shown }),
);
await page.screenshot({
path: path.join(directory, 'real-kml-visible.png'),
});
}
}
if (suppliedItem)
await page.evaluate(
(id) => window.__gevGeoImport.remove(id),
suppliedItem.id,
);
}
check(
'file-directed USGS icon/metadata requests never leave the page',
fileRequests.length === 0,
fileRequests.length,
);
errors.push(...(await readRenderErrors(page)));
check(
'no uncaught page or render errors',
errors.length === 0,
JSON.stringify(errors),
);
check(
'all imported sources are removed',
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
);
console.log('Fixture retained at', file);
} finally {
await browser.close();
}
process.exitCode = failures ? 1 : 0;
+564
View File
@@ -0,0 +1,564 @@
#!/usr/bin/env node
/** Import regressions through the real UI controller and Cesium loaders.
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
*/
import puppeteer from 'puppeteer';
import * as Cesium from 'cesium';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const meshBytes = Buffer.alloc(60);
[
-2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
[0, 1, 2, 2, 1, 3].forEach((value, i) =>
meshBytes.writeUInt16LE(value, 48 + i * 2),
);
const fixture = {
'/__qa_import_surface/tileset.json': {
asset: { version: '1.1' },
geometricError: 0,
root: {
transform: Cesium.Matrix4.toArray(
Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
),
),
boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
geometricError: 0,
content: { uri: 'surface.gltf' },
},
},
'/__qa_import_surface/surface.gltf': {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [
{
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
},
],
materials: [{ doubleSided: true }],
buffers: [
{
byteLength: 60,
uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
},
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
{ buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
],
accessors: [
{
bufferView: 0,
componentType: 5126,
count: 4,
type: 'VEC3',
min: [-2000, 2000, -2000],
max: [2000, 2000, 2000],
},
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
],
},
};
await page.setRequestInterception(true);
page.on('request', (request) => {
const body = fixture[new URL(request.url()).pathname];
if (body)
void request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
else void request.continue();
});
const errors = [],
requests = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => {
if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
});
await hookRenderErrors(page);
let failed = 0;
try {
await page.setViewport({ width: 390, height: 844 });
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__gevGeoImport &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
);
await page.evaluate(async () => {
const { viewer, dataManager } = window.__godsEyeView,
C = window.__CESIUM__;
for (const id of dataManager.layers.keys())
await dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
viewer.clock.shouldAnimate = false;
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
});
page.on('request', (r) => {
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
});
const results = await page.evaluate(async () => {
const { viewer } = window.__godsEyeView,
C = window.__CESIUM__,
tool = window.__gevGeoImport;
const results = [],
now = C.JulianDate.clone(viewer.clock.currentTime);
const check = (name, pass, detail = '') =>
results.push({ name, pass: Boolean(pass), detail });
const wrap = (body, attrs = '') =>
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ${attrs}><Document>${body}</Document></kml>`;
const point = '<Point><coordinates>-122.5,37.7,0</coordinates></Point>';
const load = async (value, name) => {
const content =
typeof value === 'object' && !ArrayBuffer.isView(value)
? JSON.stringify(value)
: value;
const [outcome] = await tool.importFiles([new File([content], name)]);
return {
outcome,
source: outcome.ok
? viewer.dataSources.get(viewer.dataSources.length - 1)
: null,
};
};
const remove = (r) => {
if (r.outcome.ok) tool.remove(r.outcome.id);
};
let r = await load(
{
type: 'FeatureCollection',
features: [
{
type: 'Feature',
id: 'kartaview-owned',
properties: {
name: 'XYZ',
addProperty: 1,
'marker-symbol': 'rail',
},
geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-122.5, 37.7, 10],
[-122.4, 37.7, 10],
[-122.4, 37.8, 10],
[-122.5, 37.7, 10],
],
],
},
},
],
},
'xyz.geojson',
);
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.billboard,
),
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.polygon,
);
check(
'XYZ ground footprint and isolated entity IDs',
r.outcome.ok &&
pin.id !== 'kartaview-owned' &&
Math.abs(
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
) < 0.01 &&
polygon.polygon.perPositionHeight?.getValue(now) !== true,
);
check(
'ground pins have no persistent clamping callbacks',
pin?.billboard.heightReference?.getValue(now) ===
C.HeightReference.NONE &&
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
);
remove(r);
r = await load(
wrap(
'<Placemark><name>ground</name><gx:Track><gx:coord>-122.5 37.7 0</gx:coord><gx:coord>-122.4 37.8 0</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>absolute</name><gx:Track><altitudeMode>absolute</altitudeMode><gx:coord>-122.5 37.7 10000</gx:coord><gx:coord>-122.4 37.8 10000</gx:coord></gx:Track></Placemark>' +
'<Placemark><name>line</name><LineString><coordinates>-122.5,37.7 -122.4,37.8</coordinates></LineString></Placemark>',
),
'tracks.kml',
);
const find = (name) =>
(r.source?.importedEntities || r.source?.entities.values).find(
(e) => e.name === name,
);
check(
'ground tracks and untessellated authored lines clamp',
find('ground')?.polyline.clampToGround?.getValue(now) &&
find('line')?.polyline.clampToGround?.getValue(now),
);
check(
'explicit track altitude and original vertices survive',
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
find('absolute')
.polyline.positions.getValue(now)
.every(
(p) =>
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
),
);
remove(r);
r = await load(
wrap(
`<Style id="s"><IconStyle><scale>0.5</scale></IconStyle></Style><Placemark><styleUrl> #s </styleUrl>${point}</Placemark>`,
),
'local-style.kml',
);
check('whitespace around a local style remains local', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark><StyleMap><Pair><key>normal</key><styleUrl>https://gev-qa-trap.invalid/style</styleUrl></Pair></StyleMap>${point}</Placemark>`,
),
'incomplete-style.kml',
);
check('removing external style does not hang the queue', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Folder><visibility>0</visibility><Placemark>${point}</Placemark></Folder>`,
),
'hidden-folder.kml',
);
check(
'import show controls own previously hidden KML content',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).every(
(e) => e.isShowing,
),
);
remove(r);
r = await load(
wrap(
'<GroundOverlay><color>ff00ff00</color><LatLonBox><north>37.8</north><south>37.7</south><east>-122.4</east><west>-122.5</west></LatLonBox></GroundOverlay>',
),
'colored.kml',
);
check(
'a colored overlay without an icon is drawable',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values).some(
(e) => e.rectangle,
),
);
remove(r);
const xml = wrap(`<Placemark>${point}</Placemark>`);
const utf16 = new Uint8Array(2 + xml.length * 2);
utf16.set([255, 254]);
for (let i = 0; i < xml.length; i++) {
utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
}
r = await load(utf16, 'utf16.kml');
check('UTF-16 BOM is decoded', r.outcome.ok);
remove(r);
r = await load(
wrap(
`<Placemark>${point}</Placemark>`,
'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
),
'namespaces.kml',
);
check(
'ordinary duplicate and missing export namespaces are repaired',
r.outcome.ok,
);
remove(r);
for (const [name, value] of [
['null geometry', { type: 'Feature', geometry: null }],
['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
[
'missing feature type',
{
type: 'FeatureCollection',
features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
},
],
[
'nested unsupported geometry',
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
],
]) {
r = await load(value, 'refused.geojson');
check(`${name} is refused without stopping rendering`, !r.outcome.ok);
remove(r);
}
r = await load(
wrap(
'<Placemark><Point><coordinates>NaN,37,0</coordinates></Point></Placemark>',
),
'invalid.kml',
);
check('non-finite KML coordinates are refused', !r.outcome.ok);
remove(r);
r = await load(
{
type: 'LineString',
coordinates: [
[-122.6, 37.7],
[-122.4, 37.7],
],
},
'portrait.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const positions = (r.source?.importedEntities ||
r.source?.entities.values)[0].polyline.positions.getValue(now);
const screen = positions?.map((p) =>
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
);
check(
'portrait flight includes both route endpoints',
screen?.every(
(p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
),
screen?.map((p) => p && [p.x, p.y]),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[-74, 40.7],
[139.7, 35.7],
],
},
'intercontinental.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
const intercontinental = (
r.source?.importedEntities || r.source?.entities.values
)
.filter((e) => e.position)
.map((e) => e.position.getValue(now));
const occluder = new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
check(
'intercontinental points remain in frame and in front of the horizon',
intercontinental?.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
return (
occluder.isPointVisible(p) &&
s &&
s.x > 0 &&
s.x < innerWidth &&
s.y > 0 &&
s.y < innerHeight
);
}),
);
remove(r);
r = await load(
wrap(
'<Placemark><LineString><altitudeMode>absolute</altitudeMode><extrude>1</extrude><coordinates>4.9,52.37,3000 5.12,52.09,3000</coordinates></LineString></Placemark><Placemark><Point><coordinates>5.12,52.09</coordinates></Point></Placemark>',
),
'wall.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2300));
check(
'a wall contributes both endpoints to the flight',
r.outcome.ok &&
(r.source.importedEntities || r.source.entities.values)
.find((e) => e.wall)
?.wall.positions.getValue(now)
.every((p) => {
const s = C.SceneTransforms.worldToWindowCoordinates(
viewer.scene,
p,
);
return (
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
);
}),
);
remove(r);
r = await load(
{
type: 'MultiPoint',
coordinates: [
[0, 0],
[180, 0],
],
},
'antipodal.geojson',
);
await new Promise((resolve) => setTimeout(resolve, 1800));
check(
'symmetric global extents keep a finite camera',
r.outcome.ok &&
Number.isFinite(viewer.camera.positionCartographic.height),
);
remove(r);
viewer.camera.cancelFlight();
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
const mesh = await C.Cesium3DTileset.fromUrl(
`${location.origin}/__qa_import_surface/tileset.json`,
);
viewer.scene.primitives.add(mesh);
// This fixture installs a mesh outside the app's map-source lifecycle.
// Let the new camera/tiles reach a rendered frame before testing sampling.
await new Promise((resolve) => {
let frames = 0;
const remove = viewer.scene.postRender.addEventListener(() => {
if (++frames >= 2) {
remove();
resolve();
} else viewer.scene.requestRender();
});
viewer.scene.requestRender();
});
const fixtureDeadline = performance.now() + 5000;
while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 50));
}
const globeShown = viewer.scene.globe.show;
viewer.scene.globe.show = false;
r = await load(
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
'mesh-point.geojson',
);
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
.find((e) => e.billboard)
?.position.getValue(now);
const meshHeight =
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
check(
'native 3D Tiles ground sampling keeps pins on the rendered mesh',
r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
{
meshHeight,
tilesLoaded: mesh.tilesLoaded,
globeShown: viewer.scene.globe.show,
},
);
tool.flyTo(r.outcome.id);
await new Promise((resolve) => setTimeout(resolve, 2300));
const meshScreen =
meshPoint &&
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
check(
'a high-surface arrival frames the elevated pin',
meshScreen &&
Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
Math.abs(meshScreen.y - innerHeight / 2) < 20,
{
screen: meshScreen && [meshScreen.x, meshScreen.y],
camera: {
height: viewer.camera.positionCartographic.height,
pitch: viewer.camera.pitch,
},
meshHeight,
},
);
const groundSource = r.source;
const elevated = await load(
wrap(
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
),
'elevated-pin.kml',
);
await new Promise((resolve) => setTimeout(resolve, 2600));
check(
'a higher imported pin never becomes the sampled ground',
elevated.outcome.ok &&
Math.abs(
C.Cartographic.fromCartesian(
groundSource.importedBillboards.get(0).position,
).height - 2000,
) < 2,
);
remove(elevated);
remove(r);
viewer.scene.primitives.remove(mesh);
viewer.scene.globe.show = globeShown;
const before = tool.list().length;
const transfer = new DataTransfer();
transfer.items.add(
new File(
[JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
'panel-drop.geojson',
),
);
const event = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
});
document.getElementById('geo-import-button').dispatchEvent(event);
for (let i = 0; i < 100 && tool.list().length === before; i++)
await new Promise((resolve) => setTimeout(resolve, 20));
check(
'a panel drop imports and prevents the browser default',
event.defaultPrevented && tool.list().length === before + 1,
);
for (const file of tool.list()) tool.remove(file.id);
check(
'the shared clock stays exactly unchanged',
C.JulianDate.equals(now, viewer.clock.currentTime),
);
check('no file sources remain', tool.list().length === 0);
return results;
});
for (const r of results) {
console.log(
`[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
JSON.stringify(r.detail),
);
if (!r.pass) failed++;
}
const renderErrors = await readRenderErrors(page);
if (errors.length || renderErrors.length) {
failed++;
console.log(
'[FAIL] browser/render errors',
JSON.stringify({ errors, renderErrors }),
);
}
// Terrain belongs to the base map; imported files may sample that provider.
const unexpected = requests.filter(
(url) =>
new URL(url).pathname !== '/logo.svg' &&
!new URL(url).pathname.startsWith('/__qa_import_surface/') &&
!/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
url,
),
);
console.log(
`[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
JSON.stringify(unexpected),
);
if (unexpected.length) failed++;
} finally {
await browser.close();
}
process.exitCode = failed ? 1 : 0;
+594
View File
@@ -0,0 +1,594 @@
#!/usr/bin/env node
/**
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
* input and a real drop, parsed by Cesium in the page.
*
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
* each become one row and one data source; the imported shape actually paints
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
* it) and stops painting when hidden; no import makes the page request
* anything named in it (network link, remote icon, remote style, palette
* icon, balloon or extended-data HTML, maki marker) or anything outside the
* page for it; no entity keeps a description, label or properties; dated KML
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
* its two segments apart; keyboard focus survives hide/show; a dropped file
* imports like a picked one; a file that is not geodata is refused with a
* reason and leaves nothing behind; Remove gives every data source back; no
* page errors.
*
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
* QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
const HEADFUL = process.env.QA_HEADFUL === '1';
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
const TRAP_HOST = 'gev-qa-trap.invalid';
/* ── fixtures ──────────────────────────────────────────────────────────── */
const ring = [
[-122.511, 37.7645],
[-122.4545, 37.7645],
[-122.4545, 37.7745],
[-122.511, 37.7745],
[-122.511, 37.7645],
];
const geojson = JSON.stringify({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
// A file's own simplestyle and HTML must not survive the import.
properties: {
name: 'QA park',
stroke: '#ff0000',
fill: '#ff0000',
'fill-opacity': 1,
description: `<img src="https://${TRAP_HOST}/props.png">`,
},
geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
},
{
type: 'Feature',
properties: { name: 'QA pin', 'marker-symbol': 'rail' },
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
},
],
});
const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
<NetworkLink><Link><href>https://${TRAP_HOST}/link.kml</href></Link></NetworkLink>
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
<description><![CDATA[<img src="https://${TRAP_HOST}/balloon.png">]]></description>
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
<ExtendedData><Data name="note"><value><![CDATA[<iframe src="https://${TRAP_HOST}/extended.html"></iframe>]]></value></Data></ExtendedData>
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
<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>
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
</Document>
</kml>`;
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
<trk><name>QA track</name><desc>&lt;img src="https://${TRAP_HOST}/gpx.png"&gt;</desc><trkseg>
<trkpt lat="37.766" lon="-122.505"><time>2018-06-01T10:00:00Z</time></trkpt><trkpt lat="37.768" lon="-122.49"><time>2018-06-01T10:05:00Z</time></trkpt>
</trkseg><trkseg>
<trkpt lat="37.770" lon="-122.48"><time>2018-06-01T11:00:00Z</time></trkpt><trkpt lat="37.772" lon="-122.47"><time>2018-06-01T11:05:00Z</time></trkpt>
</trkseg></trk>
</gpx>`;
// 1×1 PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
/** Stored (uncompressed) zip — enough for a KMZ fixture. */
function storedZip(files) {
const parts = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(n.length, 26);
const c = Buffer.alloc(46);
c.writeUInt32LE(0x02014b50, 0);
c.writeUInt32LE(data.length, 20);
c.writeUInt32LE(data.length, 24);
c.writeUInt16LE(n.length, 28);
c.writeUInt32LE(offset, 42);
parts.push(local, n, data);
central.push(c, n);
offset += 30 + n.length + data.length;
}
const dir = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(dir.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, dir, end]);
}
const kmzDoc = kml('files/pin.png').replace(
`<href>https://${TRAP_HOST}/overlay.png</href>`,
'<href>files/overlay.png</href>',
);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-'));
const fixture = (name, data) => {
const file = path.join(dir, name);
fs.writeFileSync(file, data);
return file;
};
const FILES = [
fixture('park.geojson', geojson),
fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)),
fixture(
'packed.kmz',
storedZip([
['doc.kml', Buffer.from(kmzDoc)],
['files/pin.png', PNG],
['files/overlay.png', PNG],
]),
),
fixture('walk.gpx', gpx),
];
/* ── harness ───────────────────────────────────────────────────────────── */
const browser = await puppeteer.launch({
headless: !HEADFUL,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
await hookRenderErrors(page);
const errors = [];
const trapped = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
page.on('console', (message) => {
if (/Ignoring clampToGround/.test(message.text()))
errors.push(message.text());
});
// Anything a file names, and the icon assets Cesium would load on a file's
// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
page.on('request', (request) => {
if (FILE_FETCH.some((needle) => request.url().includes(needle)))
trapped.push(request.url());
});
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const CLIP = { x: 380, y: 240, width: 480, height: 320 };
/** Pixels in the clip close to the first import color (#39d0ff). */
async function cyanPixels() {
await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
await wait(600);
const base64 = await page.screenshot({
clip: CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (b64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${b64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let n = 0;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
}
return n;
}, base64);
}
const state = () =>
page.evaluate(() => ({
files: window.__gevGeoImport.list(),
dataSources: window.__godsEyeView.viewer.dataSources.length,
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
hint: document.getElementById('geo-import-hint')?.textContent || '',
}));
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevGeoImport &&
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate(async (view) => {
const viewer = window.__godsEyeView.viewer;
for (const id of window.__godsEyeView.dataManager.layers.keys())
await window.__godsEyeView.dataManager.setEnabled(id, false);
viewer.imageryLayers.removeAll();
viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
viewer.scene.globe.enableLighting = false;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, VIEW);
await wait(4000);
await page.evaluate(() => {
const rail = document.getElementById('pp-toggles');
if (rail?.classList.contains('collapsed'))
document
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('geo-import-button')?.getBoundingClientRect()
.width > 0,
{ timeout: 10_000 },
);
const before = await state();
check(
'Import is on the DISPLAY rail with an empty, hidden list',
before.rows === 0 &&
!(await page.evaluate(() =>
document
.getElementById('geo-import-list')
.classList.contains('visible'),
)),
);
const cyanBefore = await cyanPixels();
await page.evaluate(() => {
window.__godsEyeView.viewer.clock.shouldAnimate = false;
});
const clock = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.clock;
const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
return {
start: iso(c.startTime),
stop: iso(c.stopTime),
range: c.clockRange,
step: c.clockStep,
multiplier: c.multiplier,
animate: c.shouldAnimate,
// Wall-clock drift aside, the current time must not jump to 2018/2019.
currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
};
});
const clockBefore = await clock();
const sourcesBefore = before.dataSources;
// Picked through the real <input type=file>.
const input = await page.$('#geo-import-input');
await input.uploadFile(...FILES);
const imported = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 4, {
timeout: 60_000,
})
.then(
() => true,
() => false,
);
const after = await state();
check(
'four picked files become four rows and four data sources',
imported &&
after.rows === 4 &&
after.dataSources === before.dataSources + 4,
JSON.stringify(
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
),
);
check(
'formats are recognized',
after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
);
check(
'the KML and KMZ had their outside references removed',
after.files[1].removed > 0 && after.files[2].removed > 0,
`removed ${after.files[1].removed}/${after.files[2].removed}`,
);
check(
'the hint says so',
/Added 4 files\..*left out/.test(after.hint),
after.hint,
);
const clockAfter = await clock();
check(
"dated KML and GPX leave the app's clock alone",
JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
undefined,
)),
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
);
const inside = await page.evaluate((from) => {
const viewer = window.__godsEyeView.viewer;
const now = viewer.clock.currentTime;
const sources = [];
for (let i = from; i < viewer.dataSources.length; i++)
sources.push(viewer.dataSources.get(i));
const entities = sources.flatMap(
(ds) => ds.importedEntities || ds.entities.values,
);
const named = (name) => entities.find((e) => e.name === name);
const gpx = sources[3].importedEntities || sources[3].entities.values;
const kmz = sources[2].importedEntities || sources[2].entities.values;
return {
sources: sources.length,
clocks: sources.filter((ds) => ds.clock !== undefined).length,
described: entities.filter((e) => e.description !== undefined).length,
labelled: entities.filter((e) => e.label !== undefined).length,
withProperties: entities.filter((e) => e.properties !== undefined).length,
withKml: entities.filter((e) => e.kml !== undefined).length,
hidden: entities.filter((e) => !e.isAvailable(now)).length,
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
named(name)?.billboard?.image?.getValue(now),
).length,
movingIsLine: Boolean(
named('QA moving')?.polyline && !named('QA moving')?.position,
),
movingVertices:
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
splitTracks: (sources[1].importedEntities || 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,
kmzLocalImage: kmz.some((e) =>
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
),
kmzOverlay: kmz.some((e) => e.rectangle),
groundedLines: sources
.slice(1, 3)
.every((ds) =>
(ds.importedEntities || ds.entities.values)
.filter((e) => e.polyline)
.every((e) => e.polyline.clampToGround?.getValue(now)),
),
xyzPolygon:
(sources[0].importedEntities || sources[0].entities.values)
.find((e) => e.polygon)
?.polygon.perPositionHeight?.getValue(now) !== true,
};
}, sourcesBefore);
check(
'no imported entity keeps a description, label, properties or KML data',
inside.sources === 4 &&
inside.described +
inside.labelled +
inside.withProperties +
inside.withKml ===
0,
JSON.stringify(inside),
);
check(
'no imported source has a clock, and dated features draw now',
inside.clocks === 0 &&
inside.hidden === 0 &&
inside.datedPinNow &&
inside.movingIsLine &&
inside.movingVertices === 2,
JSON.stringify(inside),
);
check(
'blocked KML icon sources use drawable local fallback pins',
inside.fallbackPins === 2,
JSON.stringify({ fallbackPins: inside.fallbackPins }),
);
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,
JSON.stringify(inside),
);
check(
'KMZ retains its own packed image and overlay',
inside.kmzLocalImage && inside.kmzOverlay,
);
check(
'XYZ polygons and KML/KMZ lines use ground geometry',
inside.xyzPolygon && inside.groundedLines,
);
await page.evaluate((from) => {
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
.entities.values)
if (entity.billboard) entity.show = false;
}, sourcesBefore);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: window.__CESIUM__.Cartesian3.fromDegrees(
view.lon,
view.lat,
view.height,
),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
const cyanShown = await cyanPixels();
await shot('imported');
check(
'the imported polygon paints on the globe',
cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
`${cyanBefore} → ${cyanShown}`,
);
const parkId = after.files[0].id;
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
const cyanHidden = await cyanPixels();
check(
'hiding it stops it painting',
cyanHidden < cyanShown / 3,
`${cyanShown} → ${cyanHidden}`,
);
await page.click(
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
);
check(
'showing it again paints it again',
(await cyanPixels()) > cyanHidden * 4,
);
// Keyboard: focus stays on the same control when the list is rebuilt.
const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
await page.focus(toggleSelector);
await page.keyboard.press('Enter');
const focusAfterHide = await page.evaluate(
(sel) => document.activeElement === document.querySelector(sel),
toggleSelector,
);
await page.keyboard.press('Enter');
const focusAfterShow = await page.evaluate(
(sel) =>
document.activeElement === document.querySelector(sel) &&
document.activeElement.getAttribute('aria-pressed') === 'true',
toggleSelector,
);
check(
'keyboard focus stays on the toggle through hide and show',
focusAfterHide && focusAfterShow,
);
// Dropped onto the globe, as a person drags from their file manager.
await page.evaluate((text) => {
const container = window.__godsEyeView.viewer.container;
const transfer = new DataTransfer();
transfer.items.add(
new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
);
for (const type of ['dragenter', 'dragover', 'drop'])
container.dispatchEvent(
new DragEvent(type, {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
}, geojson);
const dropped = await page
.waitForFunction(() => window.__gevGeoImport.list().length === 5, {
timeout: 20_000,
})
.then(
() => true,
() => false,
);
check('a file dropped on the globe imports', dropped);
// Not geodata: refused with a reason, nothing added.
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(
new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
);
window.__godsEyeView.viewer.container.dispatchEvent(
new DragEvent('drop', {
dataTransfer: transfer,
bubbles: true,
cancelable: true,
}),
);
});
await wait(800);
const refused = await state();
check(
'a file that is not geodata is refused with a reason',
refused.files.length === 5 &&
/notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
refused.hint,
);
// Remove every file through its row button.
for (const { id } of refused.files)
await page.click(
`#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
);
const cleared = await state();
check(
'Remove gives every data source back',
cleared.rows === 0 && cleared.dataSources === before.dataSources,
`${cleared.dataSources} vs ${before.dataSources}`,
);
await wait(1500);
check(
'nothing named inside a file, and no icon asset for one, was ever requested',
trapped.length === 0,
trapped.join(' '),
);
errors.push(...(await readRenderErrors(page)));
check('no page or rendering errors', errors.length === 0, errors.join(' | '));
} catch (error) {
check('harness ran to completion', false, error?.stack || String(error));
} finally {
await browser.close();
fs.rmSync(dir, { recursive: true, force: true });
}
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);