mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into feat/tools-area-breakdowns
This commit is contained in:
115 files changed
+12540
-194
No files matched your search
@@ -18,6 +18,10 @@
|
||||
"src/tooling/panelStorageDocs.test.mjs",
|
||||
"src/tooling/packageBoundaries.test.mjs",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/aircraftEmergency.test.mjs",
|
||||
"src/data/aircraftEmergencyAnalyst.test.mjs",
|
||||
"scripts/qa-emergency-squawk.mjs",
|
||||
"src/data/infrastructure.test.mjs",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
@@ -26,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",
|
||||
@@ -199,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",
|
||||
@@ -827,6 +836,16 @@
|
||||
"server/providers/firePerimeters.js",
|
||||
"src/data/cyclonesProxy.test.mjs",
|
||||
"src/data/firePerimetersProxy.test.mjs",
|
||||
"server/providers/gnssIntegrity.js",
|
||||
"src/data/gnssIntegrityProxy.test.mjs",
|
||||
"src/app/layers/gnss.js",
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/records.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.test.mjs",
|
||||
"src/layers/gnss/source.test.mjs",
|
||||
"src/layers/gnss/ownership.test.mjs",
|
||||
"scripts/qa-gnss.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
@@ -1002,5 +1021,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"
|
||||
]
|
||||
@@ -781,6 +781,7 @@
|
||||
"live-observations": {
|
||||
"exports": ["./sources/live"],
|
||||
"modules": [
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/sources/live/aircraft.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/sources/live/index.js",
|
||||
@@ -794,6 +795,7 @@
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
@@ -837,6 +839,7 @@
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
@@ -1502,6 +1505,7 @@
|
||||
"src/contextModePolicy.js",
|
||||
"src/creditKeyboard.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
@@ -2014,6 +2018,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",
|
||||
@@ -2037,6 +2044,10 @@
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/app/layers/gnss.js",
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/sources/reference.js",
|
||||
"src/search/nominatim.js",
|
||||
@@ -2225,7 +2236,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",
|
||||
@@ -2292,6 +2306,7 @@
|
||||
"src/celestialRing.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/aircraftNearbyPolicy.js",
|
||||
@@ -2629,6 +2644,10 @@
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/app/layers/gnss.js",
|
||||
"src/layers/gnss/index.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
@@ -2760,7 +2779,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",
|
||||
@@ -2825,7 +2845,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",
|
||||
@@ -2921,7 +2942,8 @@
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/aircraftClass.js"
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -2940,7 +2962,8 @@
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/aircraftClass.js"
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftEmergency.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3137,7 +3160,8 @@
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/submarineCables/bundledSource.js"
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/layers/gnss/source.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3348,6 +3372,21 @@
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"gnss": {
|
||||
"exports": ["./layers/gnss"],
|
||||
"modules": [
|
||||
"src/layers/gnss/index.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/gnss/records.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"gnss-source": {
|
||||
"exports": ["./layers/gnss/source"],
|
||||
"modules": ["src/sources/httpBody.js", "src/layers/gnss/source.js"],
|
||||
"external": []
|
||||
},
|
||||
"perimeters-source": {
|
||||
"exports": ["./layers/perimeters/source"],
|
||||
"modules": [
|
||||
@@ -3475,6 +3514,7 @@
|
||||
"src/sources/featureGeometry.js",
|
||||
"src/sources/live/standalone.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/data/aircraftEmergency.js",
|
||||
"src/sources/live/aircraft.js",
|
||||
"src/sources/live/vessels.js",
|
||||
"src/tools/places.js",
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||
* `/api/flights` and `/api/military` are stubbed in the page so the codes
|
||||
* are deterministic; everything downstream (normalizers, records, rendering,
|
||||
* Cockpit HUD) is production code.
|
||||
*
|
||||
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
|
||||
* drawn red; a normal civil flight stays white, a normal military
|
||||
* flight stays amber, and a military `minfuel` priority stays amber.
|
||||
* POLL/MODELS verify existing untracked billboards and 3D models
|
||||
* repaint when emergency status starts and clears.
|
||||
* (ii) HUD — tracking the military emergency and entering Cockpit names
|
||||
* the broadcast code on the aircraft-meta line, in red.
|
||||
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
|
||||
* normal wording (the status is not sticky).
|
||||
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
|
||||
* billboard is red, not the plain military amber.
|
||||
*
|
||||
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
|
||||
* Optional: --timeout-ms 180000 for a slow boot.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
|
||||
const CENTER = { lon: 4.0, lat: 53.5 };
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** OpenSky state vector: squawk sits at index 14. */
|
||||
const civilRow = (icao24, callsign, lon, lat, squawk) => [
|
||||
icao24,
|
||||
callsign,
|
||||
'Netherlands',
|
||||
null,
|
||||
null,
|
||||
lon,
|
||||
lat,
|
||||
10000,
|
||||
false,
|
||||
230,
|
||||
90,
|
||||
0,
|
||||
null,
|
||||
10050,
|
||||
squawk,
|
||||
false,
|
||||
0,
|
||||
];
|
||||
|
||||
/** readsb row as adsb.lol /v2/mil returns it. */
|
||||
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
|
||||
hex,
|
||||
flight,
|
||||
lon,
|
||||
lat,
|
||||
alt_baro: 24000,
|
||||
alt_geom: 24200,
|
||||
gs: 380,
|
||||
track: 270,
|
||||
t: 'C17',
|
||||
r: `QA-${hex}`,
|
||||
squawk,
|
||||
emergency,
|
||||
seen: 1,
|
||||
seen_pos: 1,
|
||||
});
|
||||
|
||||
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
|
||||
function installStubs() {
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.__squawkQaPhase = 'emergency';
|
||||
const json = (body) =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
const now = Date.now();
|
||||
const { civil, mil } = window.__squawkQaFixtures;
|
||||
if (
|
||||
url.includes('/api/flights/track') ||
|
||||
url.includes('/api/military/track')
|
||||
)
|
||||
return json({ path: [] });
|
||||
if (url.includes('/api/flights')) {
|
||||
const t = Math.floor(now / 1000);
|
||||
return json({
|
||||
time: t,
|
||||
states: civil.map((row) => {
|
||||
const copy = [...row];
|
||||
copy[3] = t - 1;
|
||||
copy[4] = t - 1;
|
||||
return copy;
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/military')) {
|
||||
const cleared = window.__squawkQaPhase === 'cleared';
|
||||
return json({
|
||||
msg: 'No error',
|
||||
now,
|
||||
ac: mil.map((row) =>
|
||||
cleared && row.hex === 'ae7700'
|
||||
? { ...row, squawk: '4521', emergency: 'none' }
|
||||
: row,
|
||||
),
|
||||
});
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
/** Colour of every shown fleet billboard whose id is one of `ids`. */
|
||||
function billboardColors(ids) {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const found = {};
|
||||
const seen = new Set();
|
||||
const visit = (primitive) => {
|
||||
if (!primitive || seen.has(primitive)) return;
|
||||
seen.add(primitive);
|
||||
if (
|
||||
typeof primitive.length !== 'number' ||
|
||||
typeof primitive.get !== 'function'
|
||||
)
|
||||
return;
|
||||
for (let i = 0; i < primitive.length; i++) {
|
||||
const item = primitive.get(i);
|
||||
if (item?.color && ids.includes(item.id) && item.show !== false) {
|
||||
found[item.id] = {
|
||||
r: Math.round(item.color.red * 255),
|
||||
g: Math.round(item.color.green * 255),
|
||||
b: Math.round(item.color.blue * 255),
|
||||
};
|
||||
} else visit(item);
|
||||
}
|
||||
};
|
||||
visit(viewer.scene.primitives);
|
||||
return found;
|
||||
}
|
||||
|
||||
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
|
||||
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
|
||||
const isAmber = (c) =>
|
||||
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
|
||||
|
||||
async function shoot(page, name) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await sleep(600);
|
||||
await page.screenshot({
|
||||
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const executablePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath().catch(() => null)) ||
|
||||
undefined;
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 180000,
|
||||
...(executablePath && fs.existsSync(executablePath)
|
||||
? { executablePath }
|
||||
: {}),
|
||||
args: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
const errors = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
const { lon, lat } = CENTER;
|
||||
await page.evaluateOnNewDocument(
|
||||
(fixtures) => {
|
||||
window.__squawkQaFixtures = fixtures;
|
||||
},
|
||||
{
|
||||
civil: [
|
||||
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
|
||||
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
|
||||
],
|
||||
mil: [
|
||||
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'),
|
||||
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
|
||||
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Stub in-page rather than with request interception: an intercepted page
|
||||
// stalls Cesium's workers (see qa-gnss.mjs).
|
||||
await page.evaluateOnNewDocument(installStubs);
|
||||
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(
|
||||
(lo, la) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const d2r = Math.PI / 180;
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight active */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
{
|
||||
longitude: lo * d2r,
|
||||
latitude: la * d2r,
|
||||
height: 260000,
|
||||
},
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
},
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
|
||||
// ── (i) TINT ──────────────────────────────────────────────────────────
|
||||
console.log('(i) TINT — emergency codes through the real layers...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('flights', true, { origin: 'user' });
|
||||
await dm.setEnabled('military', true, { origin: 'user' });
|
||||
});
|
||||
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
|
||||
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
|
||||
await page
|
||||
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
|
||||
timeout: TIMEOUT_MS,
|
||||
polling: 500,
|
||||
})
|
||||
.catch(() => {});
|
||||
await sleep(1500);
|
||||
const colors = await page.evaluate(probe);
|
||||
const show = (id) => JSON.stringify(colors[id] ?? null);
|
||||
record(
|
||||
'TINT: civil squawk 7600 is red',
|
||||
isRed(colors['4847a1']),
|
||||
show('4847a1'),
|
||||
);
|
||||
record(
|
||||
'TINT: normal civil flight stays white',
|
||||
isWhite(colors['4847a2']),
|
||||
show('4847a2'),
|
||||
);
|
||||
record(
|
||||
'TINT: military ADS-B general emergency is red',
|
||||
isRed(colors.ae7700),
|
||||
show('ae7700'),
|
||||
);
|
||||
record(
|
||||
'TINT: military minimum-fuel priority stays amber',
|
||||
isAmber(colors.ae0f01),
|
||||
show('ae0f01'),
|
||||
);
|
||||
record(
|
||||
'TINT: normal military flight stays amber',
|
||||
isAmber(colors.ae0001),
|
||||
show('ae0001'),
|
||||
);
|
||||
await shoot(page, 'globe-tint');
|
||||
|
||||
// Existing untracked billboards and models must repaint in both directions.
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaPhase = 'cleared';
|
||||
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||
});
|
||||
record(
|
||||
'POLL: existing military billboard clears to amber',
|
||||
isAmber((await page.evaluate(probe)).ae7700),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaPhase = 'emergency';
|
||||
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||
});
|
||||
record(
|
||||
'POLL: existing military billboard turns red again',
|
||||
isRed((await page.evaluate(probe)).ae7700),
|
||||
);
|
||||
const modelProbe = () => {
|
||||
const found = {};
|
||||
const visit = (items) => {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items.get(i);
|
||||
if (item.ready && item.color && typeof item.id === 'string')
|
||||
found[item.id] = {
|
||||
r: Math.round(item.color.red * 255),
|
||||
g: Math.round(item.color.green * 255),
|
||||
b: Math.round(item.color.blue * 255),
|
||||
};
|
||||
else if (item.get) visit(item);
|
||||
}
|
||||
};
|
||||
visit(window.__godsEyeView.viewer.scene.primitives);
|
||||
return found;
|
||||
};
|
||||
await page.waitForFunction(
|
||||
`(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`,
|
||||
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||
);
|
||||
const beforeModels = await page.evaluate(modelProbe);
|
||||
record(
|
||||
'MODELS: civil and military emergency models are red',
|
||||
isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700),
|
||||
JSON.stringify(beforeModels),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaFixtures.civil[0][14] = '2000';
|
||||
window.__squawkQaFixtures.civil[1][14] = '7600';
|
||||
window.__squawkQaPhase = 'cleared';
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.refreshLayer('flights');
|
||||
await dm.refreshLayer('military');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
`(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`,
|
||||
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||
);
|
||||
const afterModels = await page.evaluate(modelProbe);
|
||||
record(
|
||||
'MODELS: existing civil models start and clear, military clears',
|
||||
isWhite(afterModels['4847a1']) &&
|
||||
isRed(afterModels['4847a2']) &&
|
||||
isAmber(afterModels.ae7700),
|
||||
JSON.stringify(afterModels),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaFixtures.civil[0][14] = '7600';
|
||||
window.__squawkQaFixtures.civil[1][14] = '2000';
|
||||
window.__squawkQaFixtures.mil[0].squawk = '7700';
|
||||
window.__squawkQaPhase = 'emergency';
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.refreshLayer('flights');
|
||||
await dm.refreshLayer('military');
|
||||
});
|
||||
// ── (ii) HUD ──────────────────────────────────────────────────────────
|
||||
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
|
||||
const tracked = await page.evaluate(() =>
|
||||
Boolean(
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military')
|
||||
.module.trackById('ae7700', { origin: 'user' }),
|
||||
),
|
||||
);
|
||||
record(
|
||||
'HUD: emergency aircraft can be tracked',
|
||||
tracked,
|
||||
`tracked=${tracked}`,
|
||||
);
|
||||
// Cockpit entry is gated on the Contacts (flights) context mode.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._contextControls._selectContextMode(
|
||||
'flights',
|
||||
),
|
||||
);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const entry = document.getElementById('cockpit-entry');
|
||||
return entry && !entry.hidden && !entry.disabled;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page
|
||||
.$eval('#cockpit-entry', (entry) => entry.click())
|
||||
.catch(() => {});
|
||||
const hudReady = await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
document.body.classList.contains('cockpit-mode') &&
|
||||
/SQUAWK 7700/.test(
|
||||
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||
),
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const hud = await page.evaluate(() => {
|
||||
const meta = document.getElementById('cockpit-aircraft-meta');
|
||||
return {
|
||||
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||
text: meta?.textContent || '',
|
||||
emergencyClass: meta?.classList.contains('emergency') || false,
|
||||
color: meta ? getComputedStyle(meta).color : null,
|
||||
};
|
||||
});
|
||||
record(
|
||||
'HUD: meta line names the broadcast code',
|
||||
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
|
||||
JSON.stringify(hud),
|
||||
);
|
||||
record(
|
||||
'HUD: meta line is styled as an emergency',
|
||||
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
|
||||
`class=${hud.emergencyClass} color=${hud.color}`,
|
||||
);
|
||||
const trackedBroadcast = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('military')
|
||||
.module.getTrackedInfo().emergencyBroadcast,
|
||||
);
|
||||
record(
|
||||
'TRACKED: voice-facing status quotes the broadcast',
|
||||
trackedBroadcast === 'broadcasting squawk 7700 (general emergency)',
|
||||
trackedBroadcast,
|
||||
);
|
||||
await shoot(page, 'cockpit-hud');
|
||||
|
||||
// ── (iii) CLEAR ───────────────────────────────────────────────────────
|
||||
console.log('\n(iii) CLEAR — the next poll drops the code...');
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaPhase = 'cleared';
|
||||
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||
});
|
||||
const cleared = await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
!document
|
||||
.getElementById('cockpit-aircraft-meta')
|
||||
?.classList.contains('emergency'),
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const after = await page.evaluate(
|
||||
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||
);
|
||||
record(
|
||||
'CLEAR: HUD returns to its normal wording',
|
||||
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
|
||||
JSON.stringify(after),
|
||||
);
|
||||
await shoot(page, 'cockpit-cleared');
|
||||
|
||||
// ── (iv) RELEASE ──────────────────────────────────────────────────────
|
||||
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
|
||||
await page.evaluate(async () => {
|
||||
window.__squawkQaPhase = 'emergency';
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.refreshLayer('military');
|
||||
dm.layers.get('military').module.stopTracking({ origin: 'user' });
|
||||
});
|
||||
const releaseProbe = `(${billboardColors})(["ae7700"])`;
|
||||
const released = await page
|
||||
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
|
||||
timeout: 20000,
|
||||
polling: 250,
|
||||
})
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
|
||||
record(
|
||||
'RELEASE: untracked emergency billboard is red',
|
||||
released && isRed(releasedColor),
|
||||
JSON.stringify(releasedColor ?? null),
|
||||
);
|
||||
record(
|
||||
'no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
errors.slice(0, 3).join(' | '),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok).length;
|
||||
const fail = results.length - pass;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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><img src="https://${TRAP_HOST}/gpx.png"></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);
|
||||
@@ -0,0 +1,988 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
|
||||
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
|
||||
*
|
||||
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
|
||||
* cache, one upstream read per whole-degree anchor); the other sections
|
||||
* stub that route in the page so they are deterministic.
|
||||
*
|
||||
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
|
||||
* one geometry per cell and legend band counts that sum to the cell
|
||||
* count; cells are required only when the returned rows, binned
|
||||
* with the layer's own rules, reach the per-cell minimum (live
|
||||
* traffic varies by hour). Requires a non-stale response no older than 60 s.
|
||||
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
|
||||
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
|
||||
* with 1 degraded is low (first bad discounted), a clean cell at sea
|
||||
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
|
||||
* getStats. Every band is drawn and low is shown over land and sea.
|
||||
* The row reads as low navigation accuracy, and stats carry the
|
||||
* machine-readable provenance (GEV classifier, gpsjam bands, window).
|
||||
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
|
||||
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
|
||||
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
|
||||
* cells already on the globe are kept.
|
||||
* (iv) DISABLE — owned ground primitives are hidden.
|
||||
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
|
||||
* with Enter and off with Space, keeps focus and names its state.
|
||||
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
|
||||
* fit the panel width with no horizontal overflow.
|
||||
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
|
||||
* stale snapshot observed 31 minutes ago draws nothing and reports no
|
||||
* error; observed 29 minutes ago it draws its cells. The replay keeps
|
||||
* its observation time instead of restarting the 30-minute window.
|
||||
*
|
||||
* Screenshots wait until every cell's ground geometry is built and show the
|
||||
* open Data Layers row with its legend. Visual proof saved to
|
||||
* qa-shots/gnss-*.png (gitignored).
|
||||
*
|
||||
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
|
||||
* (also against a built preview: npm run build && npm run preview,
|
||||
* then pass the preview URL)
|
||||
* Optional: --timeout-ms 180000 for a slow boot.
|
||||
* Exits non-zero on any FAIL. Does not commit anything.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
} from '../src/layers/gnss/records.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
|
||||
const LAYER_ID = 'gnss-integrity';
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Boot the app, dismiss the intro and place the camera straight down. */
|
||||
async function boot(page, { lon, lat, height }) {
|
||||
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.evaluate(
|
||||
(lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight active */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: lo * d2r,
|
||||
latitude: la * d2r,
|
||||
height: h,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
},
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
/** Enable (or refresh) the layer and poll until it settles. */
|
||||
async function enableAndSettle(page, { refresh = false } = {}) {
|
||||
return page.evaluate(
|
||||
async (id, again) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const layer = dm.layers.get(id).module;
|
||||
if (again) await dm.refreshLayer(id);
|
||||
else await dm.setEnabled(id, true);
|
||||
const stats = layer.getStats();
|
||||
const rowControls = layer.getRowControls().legend;
|
||||
const legend = rowControls.map(({ label, count }) => ({ label, count }));
|
||||
const blurb = rowControls[0]?.blurb ?? '';
|
||||
const rowName =
|
||||
document.querySelector(`[data-layer-id="${id}"] .data-name`)
|
||||
?.textContent ?? '';
|
||||
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
return {
|
||||
stats,
|
||||
legend,
|
||||
blurb,
|
||||
rowName,
|
||||
name: layer.name,
|
||||
entities: ds?.get(0)?.geometryInstances.length ?? 0,
|
||||
shown: ds?.show ?? null,
|
||||
};
|
||||
},
|
||||
LAYER_ID,
|
||||
refresh,
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the Data Layers panel and bring the GNSS row into view. */
|
||||
async function showLayerRow(page) {
|
||||
await page.evaluate((id) => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
if (panel.classList.contains('collapsed'))
|
||||
panel.querySelector('[data-collapse-target="data-panel"]').click();
|
||||
document
|
||||
.querySelector(`[data-layer-id="${id}"]`)
|
||||
?.scrollIntoView({ block: 'center' });
|
||||
}, LAYER_ID);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
/** Actual card pixels on the shared canvas, available in dev and production. */
|
||||
function paintedOverlayPixels() {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
if (!canvas) return -1;
|
||||
const pixels = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let count = 0;
|
||||
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
async function shoot(page, name) {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => {});
|
||||
await showLayerRow(page);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
|
||||
}
|
||||
|
||||
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
|
||||
function cellStates(id) {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const all = viewer.scene.groundPrimitives;
|
||||
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
if (!ds?.length) return [];
|
||||
return [ds.get(0).ready ? 0 : 1];
|
||||
}
|
||||
|
||||
/** Wait until every GNSS cell's ground rectangle has finished building. */
|
||||
async function waitForBuiltCells(page, timeoutMs = 60000) {
|
||||
const built = await page
|
||||
.waitForFunction(
|
||||
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
|
||||
{ timeout: timeoutMs, polling: 250 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const states = await page.evaluate(
|
||||
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
|
||||
);
|
||||
return { built, detail: `states=${JSON.stringify(states)}` };
|
||||
}
|
||||
|
||||
const fixtureRow = (hex, lat, lon, degraded) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon,
|
||||
nic: degraded ? 0 : 8,
|
||||
nacp: degraded ? 0 : 10,
|
||||
gpsLost: false,
|
||||
degraded,
|
||||
});
|
||||
|
||||
/**
|
||||
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
|
||||
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
|
||||
*/
|
||||
function fixturePayload() {
|
||||
const rows = [];
|
||||
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
|
||||
for (let i = 0; i < 10; i++)
|
||||
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
|
||||
for (let i = 0; i < 30; i++)
|
||||
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
|
||||
for (let i = 0; i < 3; i++)
|
||||
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
|
||||
for (let i = 0; i < 4; i++)
|
||||
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
|
||||
for (let i = 0; i < 2; i++)
|
||||
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
|
||||
return {
|
||||
fetchedAt: Date.now(),
|
||||
anchor: { lat: 55, lon: 19 },
|
||||
radiusNm: 250,
|
||||
rows,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
|
||||
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
|
||||
await page.evaluateOnNewDocument(
|
||||
(payload, age, failures, staleOverride) => {
|
||||
window.__gnssQaAgeMs = age;
|
||||
window.__gnssQaRequests = [];
|
||||
window.__gnssQaFailures = failures;
|
||||
const realFetch = window.fetch.bind(window);
|
||||
window.__gnssQaMode = 'fixture';
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
|
||||
window.__gnssQaRequests.push(url);
|
||||
if (window.__gnssQaFailures > 0) {
|
||||
window.__gnssQaFailures--;
|
||||
return Promise.resolve(
|
||||
Response.json(
|
||||
{ error: 'rate_limited' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '5' },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const ok = window.__gnssQaMode === 'fixture';
|
||||
const body = ok
|
||||
? {
|
||||
...payload,
|
||||
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
|
||||
ageMs: window.__gnssQaAgeMs || 0,
|
||||
stale: staleOverride ?? payload.stale,
|
||||
}
|
||||
: { error: 'gnss_integrity_unavailable' };
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: ok ? 200 : 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
fixturePayload(),
|
||||
ageMs,
|
||||
initialFailures,
|
||||
stale,
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle button of the GNSS row, as the panel draws it. */
|
||||
function toggleState(id) {
|
||||
const button = document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||
);
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||
return {
|
||||
focused: document.activeElement === button,
|
||||
label: button?.getAttribute('aria-label') ?? '',
|
||||
enabled: Boolean(layer?.enabled),
|
||||
count: layer?.module.getStats().count ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
|
||||
async function pressToggle(page, key, wantEnabled) {
|
||||
await page.evaluate((id) => {
|
||||
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
|
||||
}, LAYER_ID);
|
||||
await page.keyboard.press(key);
|
||||
await page
|
||||
.waitForFunction(
|
||||
(id, want) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||
return (
|
||||
Boolean(layer?.enabled) === want &&
|
||||
(!want || layer.module.getStats().count > 0)
|
||||
);
|
||||
},
|
||||
{ timeout: 30000, polling: 250 },
|
||||
LAYER_ID,
|
||||
wantEnabled,
|
||||
)
|
||||
.catch(() => {});
|
||||
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nGNSS Integrity proof (qa-gnss)');
|
||||
console.log(` App URL : ${APP_URL}\n`);
|
||||
try {
|
||||
const res = await fetch(APP_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const executablePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
(await puppeteer.executablePath().catch(() => null)) ||
|
||||
undefined;
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(executablePath && fs.existsSync(executablePath)
|
||||
? { executablePath }
|
||||
: {}),
|
||||
args: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
],
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
const errors = [];
|
||||
try {
|
||||
// ── (i) LIVE ──────────────────────────────────────────────────────────
|
||||
console.log(
|
||||
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
|
||||
);
|
||||
const live = await browser.newPage();
|
||||
await live.setViewport({ width: 1440, height: 900 });
|
||||
live.on('pageerror', (e) => errors.push(e.message));
|
||||
const liveBodies = [];
|
||||
live.on('response', (res) => {
|
||||
if (res.url().includes('/api/gnss-integrity') && res.ok())
|
||||
liveBodies.push(res.json());
|
||||
});
|
||||
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
|
||||
const l = await enableAndSettle(live);
|
||||
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
|
||||
// Bin what adsb.lol actually returned with the layer's own rules: live
|
||||
// traffic varies by hour, so cells are required only when some cell
|
||||
// really reached the minimum aircraft count.
|
||||
const liveSnapshots = (await Promise.allSettled(liveBodies))
|
||||
.filter((r) => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
record(
|
||||
'LIVE: a successful, dated integrity response was received',
|
||||
liveSnapshots.length > 0 &&
|
||||
liveSnapshots.every(
|
||||
(body) =>
|
||||
Array.isArray(body.rows) &&
|
||||
Number.isFinite(body.ageMs) &&
|
||||
body.ageMs >= 0 &&
|
||||
body.ageMs <= 60000 &&
|
||||
body.stale !== true &&
|
||||
body.classifier === 'gev-nic-nacp-v2',
|
||||
),
|
||||
`successful responses=${liveSnapshots.length}`,
|
||||
);
|
||||
const liveRows = liveSnapshots.reduce(
|
||||
(sum, body) => sum + (body.rows?.length || 0),
|
||||
0,
|
||||
);
|
||||
const expectedStore = new Map();
|
||||
for (const body of liveSnapshots) {
|
||||
const at = Date.now();
|
||||
accumulateGnssObservations(expectedStore, body.rows, at, {
|
||||
observedAt: at - body.ageMs,
|
||||
});
|
||||
}
|
||||
const expectedCells = binGnssCells(expectedStore).length;
|
||||
record(
|
||||
'LIVE: no error, one geometry per cell',
|
||||
!l.stats.error && l.entities === l.stats.count,
|
||||
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
|
||||
);
|
||||
if (expectedCells > 0) {
|
||||
record(
|
||||
'LIVE: a snapshot with a qualifying cell yields cells',
|
||||
l.stats.count > 0,
|
||||
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
|
||||
);
|
||||
}
|
||||
record(
|
||||
'LIVE: legend band counts sum to the cell count',
|
||||
legendTotal === l.stats.count,
|
||||
JSON.stringify(l.legend),
|
||||
);
|
||||
const liveBuilt = await waitForBuiltCells(live);
|
||||
record(
|
||||
'LIVE: every cell rectangle is built on a parked camera',
|
||||
liveBuilt.built,
|
||||
liveBuilt.detail,
|
||||
);
|
||||
await shoot(live, 'live-poland-baltic');
|
||||
await live.close();
|
||||
|
||||
// ── (ii) BANDS ────────────────────────────────────────────────────────
|
||||
console.log(
|
||||
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
|
||||
);
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
// Stub the route in-page rather than with request interception: an
|
||||
// intercepted page stalls Cesium's geometry workers, so ground rectangles
|
||||
// never finish building and the screenshot would not show real behaviour.
|
||||
await stubRoute(page);
|
||||
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||
const f = await enableAndSettle(page);
|
||||
const band = (prefix) =>
|
||||
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
|
||||
record(
|
||||
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
|
||||
f.stats.count === 4 && f.entities === 4,
|
||||
`cells=${f.stats.count} entities=${f.entities}`,
|
||||
);
|
||||
record(
|
||||
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
|
||||
band('Low-accuracy score >') === 1 &&
|
||||
band('Low-accuracy score 2') === 1 &&
|
||||
band('Low-accuracy score <') === 2,
|
||||
JSON.stringify(f.legend),
|
||||
);
|
||||
const prov = f.stats.provenance;
|
||||
record(
|
||||
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
|
||||
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
|
||||
prov.classifier.definedBy === 'GEV' &&
|
||||
prov.classifier.validated === false &&
|
||||
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
|
||||
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
|
||||
prov.window?.minutes === 30 &&
|
||||
prov.window.scope === 'visited-view' &&
|
||||
prov.interpretation?.validated === false,
|
||||
JSON.stringify(prov?.classifier),
|
||||
);
|
||||
record(
|
||||
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
|
||||
f.name === 'GNSS Integrity' &&
|
||||
f.rowName.includes('GNSS Integrity') &&
|
||||
!/interference/i.test(f.rowName) &&
|
||||
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
|
||||
/one aircraft is discounted/.test(f.blurb) &&
|
||||
/not a detection/.test(f.blurb),
|
||||
`name=${JSON.stringify(f.name)}`,
|
||||
);
|
||||
record(
|
||||
'BANDS: stale snapshot is reported',
|
||||
f.stats.stale === true,
|
||||
`stale=${f.stats.stale}`,
|
||||
);
|
||||
const bandsBuilt = await waitForBuiltCells(page);
|
||||
record(
|
||||
'BANDS: every cell rectangle is built on a parked camera',
|
||||
bandsBuilt.built,
|
||||
bandsBuilt.detail,
|
||||
);
|
||||
await shoot(page, 'synthetic-bands');
|
||||
|
||||
// GNSS fills must not own map input, including over a real perimeter.
|
||||
await page.evaluate(async () => {
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url.includes('/api/fire-perimeters/inciweb'))
|
||||
return Promise.resolve(Response.json({ rows: [] }));
|
||||
if (url.includes('/api/fire-perimeters'))
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'gnss-pick-qa',
|
||||
name: 'GNSS PICK QA FIRE',
|
||||
containedPct: 0,
|
||||
acres: 100,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[18.247, 54.247],
|
||||
[18.253, 54.247],
|
||||
[18.253, 54.253],
|
||||
[18.247, 54.253],
|
||||
[18.247, 54.247],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return original(input, init);
|
||||
};
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (18.25 * Math.PI) / 180,
|
||||
latitude: (54.25 * Math.PI) / 180,
|
||||
height: 2500,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('fire-perimeters', true);
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const v = window.__godsEyeView.viewer,
|
||||
p = v.scene.pick({
|
||||
x: v.scene.canvas.clientWidth / 2,
|
||||
y: v.scene.canvas.clientHeight / 2,
|
||||
});
|
||||
return String(p?.id?.id ?? p?.id ?? '').startsWith(
|
||||
'fire-perimeter:gnss-pick-qa:',
|
||||
);
|
||||
},
|
||||
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||
);
|
||||
const pickProbe = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer,
|
||||
c = v.scene.canvas;
|
||||
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||
const empty = { x: centre.x + 300, y: centre.y };
|
||||
const picked = v.scene.pick(empty),
|
||||
box = c.getBoundingClientRect();
|
||||
return {
|
||||
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||
empty: { x: empty.x + box.left, y: empty.y + box.top },
|
||||
surface: !picked || picked.id === undefined,
|
||||
height: v.camera.positionCartographic.height,
|
||||
};
|
||||
});
|
||||
record('PICK: a perimeter remains selectable under GNSS', true);
|
||||
record(
|
||||
'PICK: street ground remains a surface pick under GNSS',
|
||||
pickProbe.surface,
|
||||
);
|
||||
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
|
||||
await page.waitForFunction(
|
||||
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
|
||||
{ timeout: 10000, polling: 250 },
|
||||
);
|
||||
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||
record(
|
||||
'PICK: actual perimeter click paints its incident card',
|
||||
selectedCardPixels > beforeCardPixels + 100,
|
||||
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
|
||||
});
|
||||
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
|
||||
await page.waitForFunction(
|
||||
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
|
||||
{ timeout: 10000, polling: 250 },
|
||||
);
|
||||
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||
record(
|
||||
'PICK: empty ground clears the perimeter card',
|
||||
clearedCardPixels <= beforeCardPixels,
|
||||
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
|
||||
);
|
||||
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
|
||||
clickCount: 2,
|
||||
});
|
||||
await sleep(1000);
|
||||
record(
|
||||
'PICK: empty double-click does not zoom to a GNSS cell',
|
||||
await page.evaluate(
|
||||
(height) =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.viewer.camera.positionCartographic.height -
|
||||
height,
|
||||
) < 200,
|
||||
pickProbe.height,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'fire-perimeters',
|
||||
false,
|
||||
);
|
||||
});
|
||||
console.log('\nCAMERA — automatic regional refresh without toggling...');
|
||||
const beforeMove = await page.evaluate(
|
||||
() => window.__gnssQaRequests.length,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (139.7 * Math.PI) / 180,
|
||||
latitude: (35.7 * Math.PI) / 180,
|
||||
height: 1_100_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const url = window.__gnssQaRequests.at(-1);
|
||||
return (
|
||||
url &&
|
||||
Math.abs(
|
||||
Number(new URL(url, location.origin).searchParams.get('lon')) -
|
||||
139.7,
|
||||
) < 1
|
||||
);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await sleep(3500);
|
||||
const moved = await page.evaluate(
|
||||
(id) => ({
|
||||
requests: window.__gnssQaRequests.length,
|
||||
enabled: window.__godsEyeView.dataManager.isEnabled(id),
|
||||
coverage: window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats().coverage,
|
||||
info: document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-controls-info`,
|
||||
)?.textContent,
|
||||
}),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
|
||||
moved.enabled &&
|
||||
moved.requests > beforeMove &&
|
||||
moved.requests <= beforeMove + 2 &&
|
||||
moved.coverage?.longitude === 140 &&
|
||||
/Regional sample.*250 nm radius/.test(moved.info),
|
||||
JSON.stringify(moved),
|
||||
);
|
||||
await shoot(page, 'camera-refresh');
|
||||
await page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (12.4 * Math.PI) / 180,
|
||||
latitude: (41.9 * Math.PI) / 180,
|
||||
height: 16_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const url = window.__gnssQaRequests.at(-1);
|
||||
const params = url && new URL(url, location.origin).searchParams;
|
||||
return (
|
||||
params &&
|
||||
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
|
||||
Math.abs(Number(params.get('lat')) - 41.9) < 1
|
||||
);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
const world = await page.evaluate((id) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const bounds = viewer.camera.computeViewRectangle(
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
return {
|
||||
enabled: dataManager.isEnabled(id),
|
||||
height: viewer.camera.positionCartographic.height,
|
||||
boundsMidLongitude:
|
||||
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
|
||||
coverage: dataManager.layers.get(id).module.getStats().coverage,
|
||||
};
|
||||
}, LAYER_ID);
|
||||
record(
|
||||
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
|
||||
world.enabled &&
|
||||
world.height > 15_000_000 &&
|
||||
world.coverage?.longitude === 12 &&
|
||||
world.coverage?.latitude === 42 &&
|
||||
world.coverage?.radiusNm === 250,
|
||||
JSON.stringify(world),
|
||||
);
|
||||
await shoot(page, 'globe-regional');
|
||||
await page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (18.6 * Math.PI) / 180,
|
||||
latitude: (54.6 * Math.PI) / 180,
|
||||
height: 450_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const url = window.__gnssQaRequests.at(-1);
|
||||
return (
|
||||
url &&
|
||||
Math.abs(
|
||||
Number(new URL(url, location.origin).searchParams.get('lon')) -
|
||||
18.6,
|
||||
) < 1
|
||||
);
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await sleep(3500);
|
||||
|
||||
// ── (iii) FAILURE ─────────────────────────────────────────────────────
|
||||
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
|
||||
await page.evaluate(() => {
|
||||
window.__gnssQaMode = 'error';
|
||||
});
|
||||
const e = await enableAndSettle(page, { refresh: true });
|
||||
record(
|
||||
'FAILURE: error surfaced, cells kept',
|
||||
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
|
||||
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
|
||||
);
|
||||
await shoot(page, 'failure');
|
||||
|
||||
// ── (iv) DISABLE ──────────────────────────────────────────────────────
|
||||
console.log('\n(iv) DISABLE...');
|
||||
const shown = await page.evaluate(async (id) => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(id, false);
|
||||
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
)?.show;
|
||||
}, LAYER_ID);
|
||||
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
|
||||
await shoot(page, 'disabled');
|
||||
|
||||
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
|
||||
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
|
||||
await page.evaluate(() => {
|
||||
window.__gnssQaMode = 'fixture';
|
||||
});
|
||||
await showLayerRow(page);
|
||||
const on = await pressToggle(page, 'Enter', true);
|
||||
record(
|
||||
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
|
||||
on.enabled &&
|
||||
on.focused &&
|
||||
on.count === 4 &&
|
||||
/^GNSS Integrity: /.test(on.label) &&
|
||||
!/OFF$/.test(on.label),
|
||||
JSON.stringify(on),
|
||||
);
|
||||
const off = await pressToggle(page, ' ', false);
|
||||
record(
|
||||
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
|
||||
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
|
||||
JSON.stringify(off),
|
||||
);
|
||||
|
||||
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
|
||||
console.log(
|
||||
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
|
||||
);
|
||||
await page.close();
|
||||
// A fresh page: switching an open page to isMobile reloads it, which
|
||||
// would measure a half-booted app instead of the open row.
|
||||
const phone = await browser.newPage();
|
||||
await phone.setViewport({ width: 390, height: 844, isMobile: true });
|
||||
phone.on('pageerror', (e) => errors.push(e.message));
|
||||
await stubRoute(phone);
|
||||
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||
await showLayerRow(phone);
|
||||
await pressToggle(phone, 'Enter', true);
|
||||
await showLayerRow(phone);
|
||||
const fit = await phone.evaluate((id) => {
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
|
||||
const rect = row.getBoundingClientRect();
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
rowLeft: Math.round(rect.left),
|
||||
rowRight: Math.round(rect.right),
|
||||
overflow: row.scrollWidth - row.clientWidth,
|
||||
legendItems: legend.length,
|
||||
coverageInfo: row.querySelector('.data-toggle-controls-info')
|
||||
?.textContent,
|
||||
legendInside: legend.every((item) => {
|
||||
const r = item.getBoundingClientRect();
|
||||
return (
|
||||
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
|
||||
);
|
||||
}),
|
||||
};
|
||||
}, LAYER_ID);
|
||||
record(
|
||||
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
|
||||
fit.rowLeft >= 0 &&
|
||||
fit.rowRight <= fit.width &&
|
||||
fit.overflow <= 0 &&
|
||||
fit.legendItems === 3 &&
|
||||
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
|
||||
fit.legendInside,
|
||||
JSON.stringify(fit),
|
||||
);
|
||||
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
|
||||
await phone.close();
|
||||
|
||||
console.log(
|
||||
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
|
||||
);
|
||||
const retry = await browser.newPage();
|
||||
await retry.setViewport({ width: 1440, height: 900 });
|
||||
retry.on('pageerror', (e) => errors.push(e.message));
|
||||
// Pages share the disposable test context's storage. Start this one cold,
|
||||
// so a previous fixture cannot auto-enable GNSS before the START test.
|
||||
await retry.evaluateOnNewDocument(() => localStorage.clear());
|
||||
await stubRoute(retry, { initialFailures: 1, stale: false });
|
||||
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||
await showLayerRow(retry);
|
||||
await retry.evaluate((id) => {
|
||||
window.__gnssQaFailedStarts = 0;
|
||||
window.__godsEyeView.dataManager.subscribe((change) => {
|
||||
if (change.layerId === id && change.type === 'visibility-failed')
|
||||
window.__gnssQaFailedStarts++;
|
||||
});
|
||||
}, LAYER_ID);
|
||||
const initial = await enableAndSettle(retry);
|
||||
const limited = await retry.evaluate((id) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||
return {
|
||||
enabled: dm.isEnabled(id),
|
||||
failedStarts: window.__gnssQaFailedStarts,
|
||||
label: row.querySelector('.data-toggle-btn').textContent,
|
||||
info: row.querySelector('.data-toggle-controls-info').textContent,
|
||||
};
|
||||
}, LAYER_ID);
|
||||
record(
|
||||
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
|
||||
limited.enabled &&
|
||||
limited.failedStarts === 0 &&
|
||||
limited.label === 'UNAVAILABLE' &&
|
||||
initial.stats.count === 0 &&
|
||||
initial.stats.retryInSec > 0 &&
|
||||
/rate limited.*Retrying automatically at/.test(limited.info),
|
||||
JSON.stringify(limited),
|
||||
);
|
||||
await retry.screenshot({
|
||||
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
|
||||
});
|
||||
await retry.waitForFunction(
|
||||
(id) => {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||
return (
|
||||
layer.enabled &&
|
||||
layer.module.getStats().error === null &&
|
||||
layer.module.getStats().count === 4
|
||||
);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
LAYER_ID,
|
||||
);
|
||||
const recovered = await retry.evaluate(
|
||||
(id) => ({
|
||||
requests: window.__gnssQaRequests.length,
|
||||
enabled: window.__godsEyeView.dataManager.isEnabled(id),
|
||||
label: document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||
).textContent,
|
||||
info: document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-controls-info`,
|
||||
).textContent,
|
||||
}),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
|
||||
recovered.enabled &&
|
||||
recovered.requests === 2 &&
|
||||
recovered.label === 'ON' &&
|
||||
!/Retrying/.test(recovered.info),
|
||||
JSON.stringify(recovered),
|
||||
);
|
||||
await shoot(retry, 'startup-recovered');
|
||||
await retry.close();
|
||||
|
||||
// ── (vii) AGE ─────────────────────────────────────────────────────────
|
||||
console.log(
|
||||
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
|
||||
);
|
||||
const aged = await browser.newPage();
|
||||
await aged.setViewport({ width: 1440, height: 900 });
|
||||
aged.on('pageerror', (e) => errors.push(e.message));
|
||||
await stubRoute(aged, { ageMs: 31 * 60_000 });
|
||||
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||
const old = await enableAndSettle(aged, {});
|
||||
record(
|
||||
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
|
||||
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
|
||||
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
|
||||
);
|
||||
await aged.evaluate(() => {
|
||||
window.__gnssQaAgeMs = 29 * 60_000;
|
||||
});
|
||||
const recent = await enableAndSettle(aged, { refresh: true });
|
||||
record(
|
||||
'AGE: the same snapshot observed 29 min ago is still inside the window',
|
||||
recent.stats.count === 4 && recent.entities === 4,
|
||||
`cells=${recent.stats.count} entities=${recent.entities}`,
|
||||
);
|
||||
await aged.close();
|
||||
record(
|
||||
'no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
errors.slice(0, 3).join(' | '),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok).length;
|
||||
const fail = results.length - pass;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
Reference in new issue
Block a user