mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge origin/main into fix/issue-362-opensky-doctor
This commit is contained in:
commit
a716b059b8
698 files changed
+189254
-23441
No files matched your search
@@ -0,0 +1,257 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { builtinModules } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { analyzeModule } from './module-analysis.mjs';
|
||||
|
||||
const builtins = new Set(
|
||||
builtinModules.flatMap((name) => [name, `node:${name}`]),
|
||||
);
|
||||
const code = /\.[mc]?js$/;
|
||||
const entry = (file) =>
|
||||
file === 'src/main.js' || file.startsWith('src/standalone/');
|
||||
const tests = (file) =>
|
||||
file.endsWith('.test.mjs') ||
|
||||
file.startsWith('src/testSupport/') ||
|
||||
file.startsWith('src/tooling/') ||
|
||||
file === 'src/overlays/worldOverlayAllocation.worker.mjs';
|
||||
const source = (file) =>
|
||||
file.startsWith('src/sources/') ||
|
||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
file,
|
||||
);
|
||||
// Two compatibility composition entries intentionally select standalone defaults.
|
||||
const compatibilityEdge = (from, to) =>
|
||||
(from === 'server/providers/local.js' &&
|
||||
to === 'server/standalone/key-setup.js') ||
|
||||
(from === 'src/ui.js' && to === 'src/standalone/catalog.js');
|
||||
const portableExport = (key) =>
|
||||
key.startsWith('./sources/') ||
|
||||
/\/source$/.test(key) ||
|
||||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
|
||||
key,
|
||||
) ||
|
||||
[
|
||||
'./director',
|
||||
'./voice/action-schemas',
|
||||
'./voice/session',
|
||||
'./data/lifecycle',
|
||||
].includes(key);
|
||||
|
||||
/** Check every runtime file, plus transitive portable/source graphs, independently of bundler reachability. */
|
||||
export function checkImportDirections(root) {
|
||||
root = realpathSync(root);
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const files = [
|
||||
...new Set(
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-files',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'--',
|
||||
'src',
|
||||
'server',
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).split('\0'),
|
||||
),
|
||||
].filter(
|
||||
(file) =>
|
||||
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
|
||||
);
|
||||
const errors = new Set();
|
||||
const modules = new Map();
|
||||
const relative = (file) =>
|
||||
path.relative(root, file).split(path.sep).join('/');
|
||||
const report = (file, message) => errors.add(`${file}: ${message}`);
|
||||
function load(file) {
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
const resolved = realpathSync(path.join(root, file));
|
||||
if (relative(resolved) !== file) {
|
||||
report(
|
||||
file,
|
||||
'Runtime modules must not alias other owners through symlinks',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let analysis;
|
||||
try {
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
||||
} catch (error) {
|
||||
report(file, error.message);
|
||||
return null;
|
||||
}
|
||||
const record = { ...analysis, edges: [], external: [] };
|
||||
modules.set(file, record);
|
||||
for (const specifier of analysis.imports) {
|
||||
let target;
|
||||
if (specifier.startsWith('.'))
|
||||
target = path.resolve(path.dirname(resolved), specifier.split('?')[0]);
|
||||
else if (specifier === pkg.name || specifier.startsWith(`${pkg.name}/`)) {
|
||||
const key =
|
||||
specifier === pkg.name ? '.' : '.' + specifier.slice(pkg.name.length);
|
||||
const exported = pkg.exports?.[key];
|
||||
if (typeof exported !== 'string') {
|
||||
report(file, `Unsupported self import: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
target = path.resolve(root, exported);
|
||||
} else {
|
||||
record.external.push(specifier);
|
||||
if (
|
||||
file.startsWith('src/') &&
|
||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
)
|
||||
report(file, `Node builtin in browser module: ${specifier}`);
|
||||
if (
|
||||
specifier.startsWith('/') ||
|
||||
(/^[a-z]+:/i.test(specifier) && !specifier.startsWith('node:'))
|
||||
)
|
||||
report(file, `Use repository or package imports: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(target)) {
|
||||
report(file, `Missing import: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
const to = relative(realpathSync(target));
|
||||
if (to.startsWith('../') || path.isAbsolute(to)) {
|
||||
report(file, `Import escapes repository: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
record.edges.push(to);
|
||||
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||
report(file, `Browser imports server: ${to}`);
|
||||
if (
|
||||
!compatibilityEdge(file, to) &&
|
||||
file.startsWith('server/providers/') &&
|
||||
(to.startsWith('server/standalone/') || renderer(to))
|
||||
)
|
||||
report(file, `Provider imports application/rendering: ${to}`);
|
||||
if (
|
||||
!compatibilityEdge(file, to) &&
|
||||
file.startsWith('src/') &&
|
||||
!entry(file) &&
|
||||
entry(to)
|
||||
)
|
||||
report(file, `Reusable module imports standalone setup: ${to}`);
|
||||
if (source(file) && renderer(to))
|
||||
report(file, `Source imports rendering/application: ${to}`);
|
||||
if (file === 'src/voice/gevActions.js' && to === 'src/data/manager.js')
|
||||
report(
|
||||
file,
|
||||
'Actions must consume feed state without the manager facade',
|
||||
);
|
||||
if (
|
||||
file === 'src/app/constructCatalog.js' &&
|
||||
to === 'src/data/localGeojson.js'
|
||||
)
|
||||
report(
|
||||
file,
|
||||
'Catalog must use the services owner without compatibility layer construction',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
for (const file of files) load(file);
|
||||
// A helper outside src must not smuggle Node or server code into a browser graph.
|
||||
const browserSeen = new Set();
|
||||
function browser(file) {
|
||||
if (browserSeen.has(file) || !code.test(file)) return;
|
||||
browserSeen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external) {
|
||||
if (builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
report(file, `Browser graph reaches Node builtin: ${specifier}`);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (to.startsWith('server/') || tests(to))
|
||||
report(file, `Browser graph reaches non-runtime owner: ${to}`);
|
||||
else browser(to);
|
||||
}
|
||||
}
|
||||
for (const file of files.filter((file) => file.startsWith('src/')))
|
||||
browser(file);
|
||||
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
|
||||
for (const [key, value] of Object.entries(pkg.exports || {})) {
|
||||
if (portableExport(key) && typeof value === 'string')
|
||||
roots.set(value.replace(/^\.\//, ''), key);
|
||||
}
|
||||
roots.set('src/data/feedState.js', 'feed state');
|
||||
for (const [start, label] of roots) {
|
||||
if (!existsSync(path.join(root, start))) {
|
||||
report(start, `Missing portable entry: ${label}`);
|
||||
continue;
|
||||
}
|
||||
const seen = new Set();
|
||||
function visit(file) {
|
||||
if (seen.has(file) || !code.test(file)) return;
|
||||
seen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
if (info.browser.length)
|
||||
report(
|
||||
file,
|
||||
`${label} reaches browser globals: ${info.browser.join(', ')}`,
|
||||
);
|
||||
for (const specifier of info.external) {
|
||||
if (
|
||||
builtins.has(specifier) ||
|
||||
specifier.startsWith('node:') ||
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
`${label} reaches platform/rendering dependency: ${specifier}`,
|
||||
);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (renderer(to) || to.startsWith('server/'))
|
||||
report(file, `${label} reaches application/rendering: ${to}`);
|
||||
else visit(to);
|
||||
}
|
||||
}
|
||||
visit(start);
|
||||
}
|
||||
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||
const voiceSeen = new Set();
|
||||
function voice(file) {
|
||||
if (voiceSeen.has(file)) return;
|
||||
voiceSeen.add(file);
|
||||
const info = load(file);
|
||||
for (const to of info?.edges || []) {
|
||||
if (/^src\/voice\/realtime/.test(to))
|
||||
report(file, `Common voice controls import protocol: ${to}`);
|
||||
else if (code.test(to)) voice(to);
|
||||
}
|
||||
}
|
||||
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
|
||||
voice('src/voice/sessionCommands.js');
|
||||
if (errors.size) throw new Error([...errors].join('\n'));
|
||||
return { modules: files.length, portableEntries: roots.size };
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
try {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
checkImportDirections(fileURLToPath(new URL('../', import.meta.url))),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+53
-11
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
@@ -11,9 +12,12 @@ PORT="${PORT:-4173}"
|
||||
HOST="${HOST:-localhost}"
|
||||
# CCTV source packs (all keyless): Austin (~815 live upstream), Caltrans
|
||||
# districts 4,7,11,3 = SF/LA/San Diego/Sacramento (~1,860 live upstream),
|
||||
# TfL London JamCams (~870 live upstream). Caps keep the densest cores per
|
||||
# pack; override per-run for lighter/heavier loads. Kill switches:
|
||||
# CCTV_CALTRANS_DISTRICTS='' and CCTV_TFL_ENABLED=0.
|
||||
# TfL London JamCams (~870 live upstream), Ontario 511 (~944 live upstream,
|
||||
# including Kitchener-area highways), and Fintraffic Finland weathercams
|
||||
# (~2,260 live presets). Caps keep the densest cores per pack; override
|
||||
# per-run for lighter/heavier loads. Kill switches: CCTV_CALTRANS_DISTRICTS='',
|
||||
# CCTV_TFL_ENABLED=0, CCTV_ONTARIO_ENABLED=0, CCTV_FINTRAFFIC_ENABLED=0 and
|
||||
# CCTV_CALGARY_ENABLED=0 (Open Calgary, ~215 live upstream).
|
||||
CCTV_AUSTIN_MAX_SOURCES="${CCTV_AUSTIN_MAX_SOURCES:-250}"
|
||||
# Use `-` not `:-` so an explicit empty string (the documented kill switch)
|
||||
# is preserved rather than replaced by the default. Still set-u-safe when unset.
|
||||
@@ -21,7 +25,26 @@ CCTV_CALTRANS_DISTRICTS="${CCTV_CALTRANS_DISTRICTS-4,7,11,3}"
|
||||
CCTV_CALTRANS_MAX_SOURCES="${CCTV_CALTRANS_MAX_SOURCES:-300}"
|
||||
CCTV_TFL_ENABLED="${CCTV_TFL_ENABLED:-1}"
|
||||
CCTV_TFL_MAX_SOURCES="${CCTV_TFL_MAX_SOURCES:-250}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-900}"
|
||||
CCTV_ONTARIO_ENABLED="${CCTV_ONTARIO_ENABLED:-1}"
|
||||
CCTV_ONTARIO_MAX_SOURCES="${CCTV_ONTARIO_MAX_SOURCES:-1000}"
|
||||
CCTV_FINTRAFFIC_ENABLED="${CCTV_FINTRAFFIC_ENABLED:-1}"
|
||||
CCTV_FINTRAFFIC_MAX_SOURCES="${CCTV_FINTRAFFIC_MAX_SOURCES:-300}"
|
||||
CCTV_DRIVEBC_ENABLED="${CCTV_DRIVEBC_ENABLED:-1}"
|
||||
CCTV_DRIVEBC_MAX_SOURCES="${CCTV_DRIVEBC_MAX_SOURCES:-250}"
|
||||
CCTV_TXDOT_ENABLED="${CCTV_TXDOT_ENABLED:-1}"
|
||||
# Use `-` so an explicit empty string (the documented kill switch) is preserved.
|
||||
CCTV_TXDOT_DISTRICTS="${CCTV_TXDOT_DISTRICTS-AUS,SAT}"
|
||||
CCTV_TXDOT_MAX_SOURCES="${CCTV_TXDOT_MAX_SOURCES:-500}"
|
||||
CCTV_TALLINN_ENABLED="${CCTV_TALLINN_ENABLED:-1}"
|
||||
CCTV_TALLINN_MAX_SOURCES="${CCTV_TALLINN_MAX_SOURCES:-255}"
|
||||
CCTV_TARKTEE_ENABLED="${CCTV_TARKTEE_ENABLED:-1}"
|
||||
CCTV_TARKTEE_MAX_SOURCES="${CCTV_TARKTEE_MAX_SOURCES:-179}"
|
||||
CCTV_WARENDORF_ENABLED="${CCTV_WARENDORF_ENABLED:-1}"
|
||||
CCTV_NSW_ENABLED="${CCTV_NSW_ENABLED:-1}"
|
||||
CCTV_NSW_MAX_SOURCES="${CCTV_NSW_MAX_SOURCES:-250}"
|
||||
CCTV_CALGARY_ENABLED="${CCTV_CALGARY_ENABLED:-1}"
|
||||
CCTV_CALGARY_MAX_SOURCES="${CCTV_CALGARY_MAX_SOURCES:-220}"
|
||||
CCTV_MAX_SOURCES="${CCTV_MAX_SOURCES:-4000}"
|
||||
|
||||
# Capture which provider credentials genuinely came from the parent shell
|
||||
# before this launcher resolves dotenv and Keychain fallbacks. Only names are
|
||||
@@ -56,7 +79,7 @@ read_dotenv_value() {
|
||||
echo "warning: node not found; cannot parse dotenv files" >&2
|
||||
return
|
||||
fi
|
||||
node scripts/read-dotenv-value.mjs "${variable_name}"
|
||||
node "$SOURCE_ROOT/scripts/read-dotenv-value.mjs" "${variable_name}"
|
||||
}
|
||||
|
||||
# Vite loads .env for browser build-time configuration, but this launcher needs
|
||||
@@ -221,13 +244,14 @@ CESIUM_ION_TOKEN="${CESIUM_ION_TOKEN:-$(read_keychain_secret "cesium-ion" "token
|
||||
TOMTOM_API_KEY="${TOMTOM_API_KEY:-$(read_keychain_secret "tomtom-api" "api-key")}"
|
||||
FIRMS_MAP_KEY="${FIRMS_MAP_KEY:-$(read_keychain_secret "firms-map" "map-key")}"
|
||||
|
||||
if [[ ! -f "src/data/cctv.js" ]]; then
|
||||
if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
|
||||
echo "error: expected CCTV layer file missing: src/data/cctv.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "dataManager.register(cctvLayer)" src/standalone/data.js; then
|
||||
echo "error: CCTV layer not wired in src/standalone/data.js"
|
||||
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
|
||||
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -285,8 +309,8 @@ case "${HOST}" in
|
||||
esac
|
||||
echo "Google Maps key source: ${GOOGLE_MAPS_API_KEY_SOURCE}"
|
||||
echo "Tip: after server starts, hard refresh browser (Cmd+Shift+R)."
|
||||
echo "If panels are still missing, run this once in browser console:"
|
||||
echo "localStorage.removeItem('godsEyeView.v6.panelPos.cctv-panel'); location.reload();"
|
||||
echo "The CCTV panel starts collapsed; open it from its header, or in browser console:"
|
||||
echo "localStorage.setItem('godsEyeView.v6.panelCollapsed.cctv-panel', '0'); location.reload();"
|
||||
echo "OpenSky auth mode: ${OPENSKY_AUTH_MODE}"
|
||||
if [[ -n "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
if [[ -f "${OPENSKY_CREDENTIALS_FILE}" ]]; then
|
||||
@@ -364,7 +388,25 @@ put_env CCTV_CALTRANS_DISTRICTS "${CCTV_CALTRANS_DISTRICTS}"
|
||||
put_env CCTV_CALTRANS_MAX_SOURCES "${CCTV_CALTRANS_MAX_SOURCES}"
|
||||
put_env CCTV_TFL_ENABLED "${CCTV_TFL_ENABLED}"
|
||||
put_env CCTV_TFL_MAX_SOURCES "${CCTV_TFL_MAX_SOURCES}"
|
||||
put_env CCTV_ONTARIO_ENABLED "${CCTV_ONTARIO_ENABLED}"
|
||||
put_env CCTV_ONTARIO_MAX_SOURCES "${CCTV_ONTARIO_MAX_SOURCES}"
|
||||
put_env_if_set TFL_APP_KEY "${TFL_APP_KEY:-}"
|
||||
put_env CCTV_FINTRAFFIC_ENABLED "${CCTV_FINTRAFFIC_ENABLED}"
|
||||
put_env CCTV_FINTRAFFIC_MAX_SOURCES "${CCTV_FINTRAFFIC_MAX_SOURCES}"
|
||||
put_env CCTV_DRIVEBC_ENABLED "${CCTV_DRIVEBC_ENABLED}"
|
||||
put_env CCTV_DRIVEBC_MAX_SOURCES "${CCTV_DRIVEBC_MAX_SOURCES}"
|
||||
put_env CCTV_TXDOT_ENABLED "${CCTV_TXDOT_ENABLED}"
|
||||
put_env CCTV_TXDOT_DISTRICTS "${CCTV_TXDOT_DISTRICTS}"
|
||||
put_env CCTV_TXDOT_MAX_SOURCES "${CCTV_TXDOT_MAX_SOURCES}"
|
||||
put_env CCTV_TALLINN_ENABLED "${CCTV_TALLINN_ENABLED}"
|
||||
put_env CCTV_TALLINN_MAX_SOURCES "${CCTV_TALLINN_MAX_SOURCES}"
|
||||
put_env CCTV_TARKTEE_ENABLED "${CCTV_TARKTEE_ENABLED}"
|
||||
put_env CCTV_TARKTEE_MAX_SOURCES "${CCTV_TARKTEE_MAX_SOURCES}"
|
||||
put_env CCTV_WARENDORF_ENABLED "${CCTV_WARENDORF_ENABLED}"
|
||||
put_env CCTV_NSW_ENABLED "${CCTV_NSW_ENABLED}"
|
||||
put_env CCTV_NSW_MAX_SOURCES "${CCTV_NSW_MAX_SOURCES}"
|
||||
put_env CCTV_CALGARY_ENABLED "${CCTV_CALGARY_ENABLED}"
|
||||
put_env CCTV_CALGARY_MAX_SOURCES "${CCTV_CALGARY_MAX_SOURCES}"
|
||||
put_env CCTV_MAX_SOURCES "${CCTV_MAX_SOURCES}"
|
||||
put_env OPENSKY_AUTH_MODE "${OPENSKY_AUTH_MODE}"
|
||||
put_env_if_set OPENSKY_CREDENTIALS_FILE "${OPENSKY_CREDENTIALS_FILE}"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
PORT="${PORT:-4173}"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"roots": ["src", "server"]
|
||||
}
|
||||
+236
-2
@@ -5,12 +5,23 @@
|
||||
"scripts/package-boundaries.json",
|
||||
"scripts/format.mjs",
|
||||
"scripts/check-package-boundaries.mjs",
|
||||
"scripts/precompute-cctv-heights.mjs",
|
||||
"src/tooling/precomputeCctvHeights.test.mjs",
|
||||
"src/tooling/fixtureRoot.mjs",
|
||||
"src/tooling/fixtureRoot.test.mjs",
|
||||
"src/tooling/format.test.mjs",
|
||||
"src/tooling/panelStorageDocs.test.mjs",
|
||||
"src/tooling/packageBoundaries.test.mjs",
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/infrastructure.test.mjs",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMode.js",
|
||||
"src/annotations/drawMode.test.mjs",
|
||||
"src/annotations/drawTool.js",
|
||||
"src/annotations/drawTool.test.mjs",
|
||||
"src/data/inputOwnership.test.mjs",
|
||||
"docs/CODE-BOUNDARIES.md",
|
||||
"src/main.js",
|
||||
"docs/APPLICATION.md",
|
||||
@@ -71,6 +82,8 @@
|
||||
"src/data/firmsCsv.test.mjs",
|
||||
"src/tooling/environmentProviders.test.mjs",
|
||||
"server/providers/cctv.js",
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
@@ -110,6 +123,9 @@
|
||||
"server/providers/regional/http.js",
|
||||
"server/providers/regional/news.js",
|
||||
"server/providers/regional/place.js",
|
||||
"src/nominatimGeocode.js",
|
||||
"src/nominatimGeocode.test.mjs",
|
||||
"src/tooling/nominatimSearchRoute.test.mjs",
|
||||
"server/providers/regional/query.js",
|
||||
"server/providers/regional/weather-effects.js",
|
||||
"server/providers/regional/weather.js",
|
||||
@@ -118,6 +134,11 @@
|
||||
"src/search/index.js",
|
||||
"src/search/placeSearch.js",
|
||||
"src/search/google.js",
|
||||
"src/search/coordinateParser.js",
|
||||
"src/search/coordinateParser.test.mjs",
|
||||
"src/search/coordinateGeocoder.js",
|
||||
"src/search/presetGeocoder.js",
|
||||
"src/search/offlineGeocoders.test.mjs",
|
||||
"src/keylessGeocoder.js",
|
||||
"src/standalone/placeSearch.js",
|
||||
"src/ui/panelDisclosure.js",
|
||||
@@ -137,6 +158,8 @@
|
||||
"scripts/qa-visual-input.mjs",
|
||||
"src/ui/displayControls.js",
|
||||
"src/ui/displayControls.test.mjs",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/cameraOrientationControls.test.mjs",
|
||||
"src/ui/effects.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/ui/visualPresets.js",
|
||||
@@ -149,6 +172,7 @@
|
||||
"src/styles/noir.js",
|
||||
"src/styles/snow.js",
|
||||
"scripts/qa-visual-effects.mjs",
|
||||
"scripts/qa-draw-tool.mjs",
|
||||
"src/ui/mapSource.js",
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/ui/mapSourceControls.test.mjs",
|
||||
@@ -156,8 +180,11 @@
|
||||
"scripts/qa-map-source-controls.mjs",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/layerKeyRequirement.test.mjs",
|
||||
"src/ui/layerPanel.test.mjs",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"scripts/qa-layer-panel.mjs",
|
||||
"scripts/qa-directions.mjs",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/ui/location.js",
|
||||
@@ -299,7 +326,6 @@
|
||||
"src/layers/vessels/rendering.js",
|
||||
"src/layers/vessels/selection.js",
|
||||
"src/layers/vessels/state.js",
|
||||
"src/layers/vessels/store.js",
|
||||
"src/layers/vessels/testing.js",
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/layers/vessels/ownership.test.mjs",
|
||||
@@ -426,6 +452,7 @@
|
||||
"src/data/flowTiles.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
"src/layers/bikeshare/source.test.mjs",
|
||||
"src/layers/bikeshare/proxyRoute.test.mjs",
|
||||
"scripts/traffic-fixtures.mjs",
|
||||
"src/layers/traffic/navigation.test.mjs",
|
||||
"scripts/qa-traffic-navigation.mjs",
|
||||
@@ -455,7 +482,29 @@
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/data/cctv.js",
|
||||
"src/data/cctvCatalogCap.test.mjs",
|
||||
"src/data/cctvDisplayCode.test.mjs",
|
||||
"src/data/cctvFootprint.js",
|
||||
"src/data/cctvGroundHeights.test.mjs",
|
||||
"src/data/cctvFootprint.test.mjs",
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
"src/data/cctvTxdotSource.test.mjs",
|
||||
"src/layers/cctv/source.test.mjs",
|
||||
"src/data/alprCameras.js",
|
||||
"src/data/alprCameras.test.mjs",
|
||||
"src/data/dataCredits.test.mjs",
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/overlay.test.mjs",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/source.test.mjs",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
@@ -511,5 +560,190 @@
|
||||
"src/mapStackChips.test.mjs",
|
||||
"src/mapStackUnavailableReason.test.mjs",
|
||||
"src/mapStartup.test.mjs",
|
||||
"src/maps/sourceFactories.test.mjs"
|
||||
"src/maps/sourceFactories.test.mjs",
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js",
|
||||
"src/search/geospatial.test.mjs",
|
||||
"src/voice/commands.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"build/application-html.js",
|
||||
"src/app/sourceSlot.js",
|
||||
"src/app/sources.js",
|
||||
"src/ui/composition.js",
|
||||
"src/services/application.js",
|
||||
"src/services/requests.js",
|
||||
"src/app/scene.js",
|
||||
"src/app/controls.js",
|
||||
"src/app/data.js",
|
||||
"src/app/tools.js",
|
||||
"src/app/startupChrome.js",
|
||||
"src/app/errors.js",
|
||||
"scripts/project-root.mjs",
|
||||
"src/toolProjectRoot.test.mjs",
|
||||
"src/app/catalog.js",
|
||||
"src/standalone/catalog.js",
|
||||
"src/app/catalog.test.mjs",
|
||||
"src/app/layers/militaryInstallations.js",
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/traffic.js",
|
||||
"src/app/layers/submarineCables.js",
|
||||
"src/app/layers/militaryFlights.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/flights.js",
|
||||
"src/app/layers/radio.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/satellites.js",
|
||||
"src/app/layers/militaryAwareness.js",
|
||||
"src/app/layers/rocketLaunches.js",
|
||||
"src/app/layers/overlayHost.js",
|
||||
"src/app/layers/aisLiveVessels.js",
|
||||
"src/app/constructCatalog.js",
|
||||
"src/standalone/layerSources.js",
|
||||
"src/app/constructCatalog.test.mjs",
|
||||
"src/app/surfaceServices.js",
|
||||
"src/app/operations.js",
|
||||
"src/data/surfaceServices.js",
|
||||
"src/annotations/resolver.js",
|
||||
"src/services/terrainHeights.js",
|
||||
"src/services/groundFloor.js",
|
||||
"src/services/meshFloorSampler.js",
|
||||
"src/services/groundSnap.js",
|
||||
"src/app/operations.test.mjs",
|
||||
"src/data/lifecycle.js",
|
||||
"src/data/manager.js",
|
||||
"src/app/layerPresentation.js",
|
||||
"src/ui/navigationController.js",
|
||||
"src/ui/shareRestoration.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/ui/panelChrome.js",
|
||||
"src/testSupport/readShellSource.mjs",
|
||||
"src/ui/stateOwners.test.mjs",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/records.test.mjs",
|
||||
"src/layers/flights/ingestion.test.mjs",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/layers/military/records.test.mjs",
|
||||
"src/layers/military/ingestion.test.mjs",
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/records.test.mjs",
|
||||
"src/layers/vessels/ingestion.test.mjs",
|
||||
"src/cityPresets.test.mjs",
|
||||
"server/providers/cctv/range.js",
|
||||
"src/data/cctvMediaRange.test.mjs",
|
||||
"src/materialSymbolsSubset.test.mjs",
|
||||
"scripts/pinokio-update.mjs",
|
||||
"src/pinokioUpdatePreview.test.mjs",
|
||||
"src/data/routeSteps.js",
|
||||
"src/data/routeSteps.test.mjs",
|
||||
"src/data/directions.js",
|
||||
"src/data/directions.test.mjs",
|
||||
"src/app/layers/directions.js",
|
||||
"src/layers/directions/index.js",
|
||||
"src/sources/radioBrowser.js",
|
||||
"src/sources/cctvTypes.js",
|
||||
"src/sources/protocols.test.mjs",
|
||||
"src/voice/actionSchemas.js",
|
||||
"src/voice/actionSchemas.test.mjs",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/session.test.mjs",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/sources/sourceSlot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"scripts/module-analysis.mjs",
|
||||
"scripts/check-import-directions.mjs",
|
||||
"src/tooling/importDirections.test.mjs",
|
||||
"src/sources/reference.js",
|
||||
"src/sources/reference.test.mjs",
|
||||
"src/search/nominatim.js",
|
||||
"src/search/nominatim.test.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/nominatim.js",
|
||||
"server/providers/transit.js",
|
||||
"server/providers/transitHistory.js",
|
||||
"server/providers/transitHistory.test.mjs",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitFeeds.test.mjs",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/transitProxy.test.mjs",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/gtfsRealtime.test.mjs",
|
||||
"src/data/transit.js",
|
||||
"src/data/transit.test.mjs",
|
||||
"src/layers/transit/controls.js",
|
||||
"src/layers/transit/height.js",
|
||||
"src/layers/transit/index.js",
|
||||
"src/layers/transit/ingestion.js",
|
||||
"src/layers/transit/lifecycle.js",
|
||||
"src/layers/transit/policy.js",
|
||||
"src/layers/transit/queries.js",
|
||||
"src/layers/transit/rendering.js",
|
||||
"src/layers/transit/selection.js",
|
||||
"src/layers/transit/state.js",
|
||||
"src/layers/transit/testing.js",
|
||||
"src/layers/transit/viewport.js",
|
||||
"src/layers/transit/lifecycle.test.mjs",
|
||||
"scripts/qa-transit.mjs",
|
||||
"scripts/qa-transit-heading.mjs",
|
||||
"src/tooling/transitProvider.test.mjs",
|
||||
"src/app/layers/transit.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/contactTrailRenderer.test.mjs",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/data/contactPlayback.test.mjs",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/movement.test.mjs",
|
||||
"src/data/transitPresetStyle.js",
|
||||
"src/data/transitPresetStyle.test.mjs",
|
||||
"src/data/transitIcons.test.mjs",
|
||||
"src/overlays/worldOverlayTokens.test.mjs",
|
||||
"src/app/layers/transit.test.mjs",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
"src/layers/transit/qaMetrics.test.mjs",
|
||||
"scripts/qa-transit-scenes.mjs",
|
||||
"scripts/qa-transit-controls.mjs",
|
||||
"src/tooling/transitQa.test.mjs",
|
||||
"scripts/qa-transit-browser.mjs",
|
||||
"scripts/qa-transit-recovery.mjs",
|
||||
"scripts/format-runtime.json",
|
||||
"src/tooling/transitService.test.mjs",
|
||||
"src/sources/overpassFeatures.test.mjs",
|
||||
"src/director/clock.test.mjs",
|
||||
"src/director/timeline.test.mjs",
|
||||
"src/scenes/packs/registry.test.mjs",
|
||||
"scripts/qa-director-timing.mjs",
|
||||
"src/director/document.test.mjs",
|
||||
"src/director/camera.test.mjs",
|
||||
"src/scenes/cameraMotion.test.mjs",
|
||||
"scripts/qa-director-camera.mjs",
|
||||
"src/director/packs/packs.test.mjs",
|
||||
"scripts/qa-director-packs.mjs",
|
||||
"src/scenes/dataPacks/presentation.test.mjs",
|
||||
"src/director/interactions/interactions.test.mjs",
|
||||
"scripts/qa-director-interactions.mjs",
|
||||
"src/director/sharing/sharing.test.mjs",
|
||||
"scripts/qa-director-sharing.mjs",
|
||||
"src/data/aisStreamSentinels.test.mjs"
|
||||
]
|
||||
+56
-3
@@ -1,9 +1,58 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
/** Check or format the explicitly adopted files, without scanning other source. */
|
||||
/** Discover owned runtime code; tests and other files retain explicit adoption. */
|
||||
export async function discoverRuntimeFormatFiles(root) {
|
||||
const configFile = path.join(root, 'scripts/format-runtime.json');
|
||||
if (!existsSync(configFile)) return [];
|
||||
const { roots } = JSON.parse(await readFile(configFile, 'utf8'));
|
||||
if (
|
||||
!Array.isArray(roots) ||
|
||||
!roots.length ||
|
||||
new Set(roots).size !== roots.length ||
|
||||
roots.some(
|
||||
(name) =>
|
||||
typeof name !== 'string' ||
|
||||
!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(name),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Runtime formatting roots must be unique repository directory paths',
|
||||
);
|
||||
const candidates = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'ls-files',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
'--',
|
||||
...roots,
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).split('\0');
|
||||
const files = [];
|
||||
for (const name of new Set(candidates)) {
|
||||
if (
|
||||
!/\.[cm]?js$/.test(name) ||
|
||||
/\.test\.[cm]?js$/.test(name) ||
|
||||
!existsSync(path.join(root, name))
|
||||
)
|
||||
continue;
|
||||
const info = await prettier.getFileInfo(path.join(root, name), {
|
||||
ignorePath: path.join(root, '.prettierignore'),
|
||||
});
|
||||
if (!info.ignored) files.push(name);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/** Check or format explicit adoption plus automatically discovered runtime code. */
|
||||
export async function formatAdoptedFiles(root, mode) {
|
||||
if (!['--check', '--write'].includes(mode)) {
|
||||
throw new Error('Usage: node scripts/format.mjs --check|--write');
|
||||
@@ -22,9 +71,13 @@ export async function formatAdoptedFiles(root, mode) {
|
||||
);
|
||||
}
|
||||
|
||||
const names = [
|
||||
...new Set([...scope, ...(await discoverRuntimeFormatFiles(root))]),
|
||||
];
|
||||
|
||||
// Validate the whole scope before any write, including resolved symlink paths.
|
||||
const files = [];
|
||||
for (const name of scope) {
|
||||
for (const name of names) {
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
!name ||
|
||||
@@ -91,7 +144,7 @@ if (import.meta.url === invoked) {
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} adopted files.`,
|
||||
`${mode === '--write' ? 'Formatted' : 'Checked'} ${result.count} source files.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
|
||||
const browserGlobals = new Set([
|
||||
'window',
|
||||
'document',
|
||||
'navigator',
|
||||
'HTMLElement',
|
||||
'HTMLCanvasElement',
|
||||
'Image',
|
||||
'Audio',
|
||||
'localStorage',
|
||||
'sessionStorage',
|
||||
'requestAnimationFrame',
|
||||
'cancelAnimationFrame',
|
||||
'getComputedStyle',
|
||||
]);
|
||||
|
||||
/** Inspect literal module dependencies and browser-platform references without executing code. */
|
||||
export function analyzeModule(source) {
|
||||
const imports = [];
|
||||
const browser = new Set();
|
||||
function walk(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (
|
||||
[
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'ImportExpression',
|
||||
].includes(node.type) &&
|
||||
node.source
|
||||
) {
|
||||
if (node.source.type !== 'StringLiteral')
|
||||
throw new Error(
|
||||
'Computed module imports are not allowed in runtime code',
|
||||
);
|
||||
imports.push(node.source.value);
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
||||
throw new Error('Runtime modules must use ES imports');
|
||||
if (node.type === 'Identifier' && browserGlobals.has(node.name)) {
|
||||
const property =
|
||||
(parent?.type === 'MemberExpression' ||
|
||||
parent?.type === 'OptionalMemberExpression') &&
|
||||
field === 'property' &&
|
||||
!parent.computed;
|
||||
const key =
|
||||
['ObjectProperty', 'ObjectMethod', 'ClassMethod'].includes(
|
||||
parent?.type,
|
||||
) &&
|
||||
field === 'key' &&
|
||||
!parent.computed;
|
||||
const declaration =
|
||||
field === 'id' ||
|
||||
field === 'params' ||
|
||||
parent?.type?.startsWith('Import');
|
||||
if (!property && !key && !declaration) browser.add(node.name);
|
||||
}
|
||||
if (
|
||||
['MemberExpression', 'OptionalMemberExpression'].includes(node.type) &&
|
||||
['globalThis', 'self'].includes(node.object?.name)
|
||||
) {
|
||||
const name = node.computed ? node.property?.value : node.property?.name;
|
||||
if (browserGlobals.has(name)) browser.add(name);
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
||||
if (Array.isArray(value))
|
||||
value.forEach((child) => walk(child, node, key));
|
||||
else if (value && typeof value === 'object') walk(value, node, key);
|
||||
}
|
||||
}
|
||||
walk(parsers.babel.parse(source));
|
||||
return { imports, browser: [...browser] };
|
||||
}
|
||||
|
||||
/** Return static imports, literal dynamic imports and re-exports. */
|
||||
export function moduleImports(source) {
|
||||
return analyzeModule(source).imports;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SOURCE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT_DIR="$(cd "${GEV_PROJECT_ROOT:-$SOURCE_ROOT}" && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if ! command -v security >/dev/null 2>&1; then
|
||||
|
||||
+1744
-35
File diff suppressed because it is too large.
Load diff
+237
-3
@@ -1,5 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
import { installPinokioDependencies, runChecked } from './pinokio-install.mjs';
|
||||
/**
|
||||
* Pinokio "Update": disclose what is about to land, then fast-forward and reinstall.
|
||||
*
|
||||
* Update is a fixed menu item aimed at people who only want to run the app, and
|
||||
* it executes install scripts from whatever the configured remote serves. It
|
||||
* cannot ask for confirmation — Pinokio drives it non-interactively — so the
|
||||
* safety it can offer is disclosure: fetch once, print the remote it fetched
|
||||
* from plus the incoming commits and their diffstat, and then apply that exact
|
||||
* revision. Someone who sees an unfamiliar remote or an unexpected set of
|
||||
* commits can close the window before any install script runs.
|
||||
*
|
||||
* @module scripts/pinokio-update
|
||||
*/
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
installPinokioDependencies,
|
||||
isDirectInvocation,
|
||||
runChecked,
|
||||
} from './pinokio-install.mjs';
|
||||
|
||||
runChecked('git', ['pull', '--ff-only']);
|
||||
installPinokioDependencies();
|
||||
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||
|
||||
/**
|
||||
* Read a git value.
|
||||
*
|
||||
* @param {string[]} args - git arguments.
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cwd] - Repository to read.
|
||||
* @returns {string|null} Trimmed stdout, `''` when git succeeded with no
|
||||
* output, or null when git failed (no upstream, detached HEAD, not a
|
||||
* checkout). Callers must keep those two apart: an empty range and an
|
||||
* unreadable one mean very different things to someone about to install.
|
||||
*/
|
||||
export function readGit(args, { cwd = ROOT } = {}) {
|
||||
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
if (result.error || result.status !== 0) return null;
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credentials from a remote URL before it is printed.
|
||||
*
|
||||
* A remote can carry a password or an access token in its userinfo
|
||||
* (`https://token@host/org/repo.git`) or in a query parameter
|
||||
* (`?access_token=…`). The point of printing the remote is to show WHERE the
|
||||
* update comes from, which the host and path already say — so the userinfo is
|
||||
* replaced and the query string and fragment are dropped whole, rather than
|
||||
* matched against a list of parameter names that would miss the next one.
|
||||
*
|
||||
* A secret spelled as an ordinary path segment is not detected; nothing here
|
||||
* can tell one of those from a repository name.
|
||||
*
|
||||
* @param {string|null|undefined} url - Remote URL as git reports it.
|
||||
* @returns {string|null} Printable URL, or null when there is nothing to print.
|
||||
*/
|
||||
export function redactRemoteUrl(url) {
|
||||
if (typeof url !== 'string' || url.length === 0) return null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
// Not a URL: scp-style `git@host:org/repo.git`, or a local path. Neither
|
||||
// has a userinfo field to hide.
|
||||
return url;
|
||||
}
|
||||
const hadUserinfo = Boolean(parsed.username || parsed.password);
|
||||
const hadQuery = Boolean(parsed.search || parsed.hash);
|
||||
if (!hadUserinfo && !hadQuery) return url;
|
||||
parsed.password = '';
|
||||
if (hadUserinfo) parsed.username = '***';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the remote, the commits about to be applied, and their diffstat.
|
||||
*
|
||||
* @param {object} [io] - Injection seam for tests.
|
||||
* @param {string} [io.cwd] - Repository to inspect.
|
||||
* @param {(line: string) => void} [io.log] - Normal output.
|
||||
* @param {(line: string) => void} [io.warn] - Degraded-path output.
|
||||
* @param {(remote: string) => void} [io.fetchRemote] - Fetch step; throws or
|
||||
* exits on failure, because a report built on a stale remote ref describes
|
||||
* the wrong changes.
|
||||
* @param {(args: string[]) => (string|null)} [io.read] - git reader.
|
||||
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}}
|
||||
* `apply` is the exact revision that was disclosed and must now be applied.
|
||||
* `fallback` asks the caller to run the plain pull so git itself reports
|
||||
* whatever went wrong, and is only ever set before anything has been fetched.
|
||||
* `stopped` means the update gave up: nothing can be shown, so nothing is
|
||||
* applied.
|
||||
*/
|
||||
export function reportIncomingChanges(io = {}) {
|
||||
const {
|
||||
cwd = ROOT,
|
||||
log = (line) => console.log(line),
|
||||
warn = (line) => console.warn(line),
|
||||
fetchRemote = (remote) => runChecked('git', ['fetch', '--quiet', remote]),
|
||||
read = (args) => readGit(args, { cwd }),
|
||||
} = io;
|
||||
|
||||
const upstream = read([
|
||||
'rev-parse',
|
||||
'--abbrev-ref',
|
||||
'--symbolic-full-name',
|
||||
'@{u}',
|
||||
]);
|
||||
// The full ref name is what later reads resolve, so a remote whose own name
|
||||
// contains a slash (`team/origin`) cannot be mistaken for a path segment.
|
||||
const upstreamRef = read(['rev-parse', '--symbolic-full-name', '@{u}']);
|
||||
const branch = read(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
const remote =
|
||||
branch && branch !== 'HEAD'
|
||||
? read(['config', '--get', `branch.${branch}.remote`])
|
||||
: null;
|
||||
if (!upstream || !upstreamRef || !remote) {
|
||||
warn('[update] No upstream branch is configured — cannot preview changes.');
|
||||
warn('[update] Continuing; `git pull --ff-only` will report the problem.');
|
||||
return { apply: null, fallback: true };
|
||||
}
|
||||
|
||||
log(`[update] Tracking ${upstream}`);
|
||||
if (remote === '.') {
|
||||
// The upstream is another branch in this same checkout; there is nothing
|
||||
// to fetch and no remote URL to disclose.
|
||||
log('[update] Upstream is a local branch — nothing to fetch.');
|
||||
} else {
|
||||
const url = redactRemoteUrl(read(['remote', 'get-url', remote]));
|
||||
log(`[update] Fetching from ${url || remote}`);
|
||||
// Fetch once, here. Everything below describes and then applies the
|
||||
// revision this fetch brought in; a second fetch inside `git pull` could
|
||||
// apply something newer than what was printed.
|
||||
fetchRemote(remote);
|
||||
}
|
||||
|
||||
const target = read(['rev-parse', upstreamRef]);
|
||||
const head = read(['rev-parse', 'HEAD']);
|
||||
if (!target || !head) {
|
||||
// Stop. Falling through to `git pull` here would fetch a second time and
|
||||
// could install a revision this run never resolved, let alone disclosed.
|
||||
warn(`[update] Could not resolve ${upstream} after fetching.`);
|
||||
warn(
|
||||
'[update] Stopping: nothing is applied, because nothing can be shown.',
|
||||
);
|
||||
return { apply: null, fallback: false, stopped: true };
|
||||
}
|
||||
if (target === head) {
|
||||
log('[update] Already up to date — reinstalling dependencies only.');
|
||||
return { apply: null, fallback: false };
|
||||
}
|
||||
|
||||
const commits = read([
|
||||
'log',
|
||||
'--oneline',
|
||||
'--no-decorate',
|
||||
`${head}..${target}`,
|
||||
]);
|
||||
if (commits === null) {
|
||||
// A read that failed is not an empty range. Saying "up to date" here would
|
||||
// tell someone nothing is arriving while a revision is about to be applied.
|
||||
warn(`[update] Could not list the commits in ${head}..${target}.`);
|
||||
warn(
|
||||
`[update] Applying ${target} anyway; it is the revision that was fetched.`,
|
||||
);
|
||||
return { apply: target, fallback: false };
|
||||
}
|
||||
|
||||
const lines = commits.length > 0 ? commits.split('\n') : [];
|
||||
if (lines.length > 0) {
|
||||
log(`\n[update] ${lines.length} incoming commit(s):`);
|
||||
for (const line of lines) log(` ${line}`);
|
||||
} else {
|
||||
log(
|
||||
`\n[update] ${upstream} is at ${target}, which is not ahead of this checkout.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stat = read(['diff', '--stat', `${head}..${target}`]);
|
||||
if (stat === null) {
|
||||
warn(`[update] Could not read the diffstat for ${head}..${target}.`);
|
||||
} else if (stat) {
|
||||
log('\n[update] Files affected:');
|
||||
for (const line of stat.split('\n')) log(` ${line}`);
|
||||
}
|
||||
log(`\n[update] Applying ${target}, then reinstalling dependencies.\n`);
|
||||
return { apply: target, fallback: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disclose, then apply exactly what was disclosed.
|
||||
*
|
||||
* @param {object} [io] - Same seam as {@link reportIncomingChanges}, plus
|
||||
* `apply`, which runs one git command and must fail loudly.
|
||||
* @returns {{apply: string|null, fallback: boolean}} What the report decided.
|
||||
*/
|
||||
export function updateFromRemote(io = {}) {
|
||||
const { apply = (args) => runChecked('git', args) } = io;
|
||||
const plan = reportIncomingChanges(io);
|
||||
// `merge --ff-only <revision>` applies the object the report named. A second
|
||||
// `pull` would fetch again and could land a different one.
|
||||
if (plan.apply) apply(['merge', '--ff-only', plan.apply]);
|
||||
else if (plan.fallback) apply(['pull', '--ff-only']);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* What running this script does: disclose, apply, install.
|
||||
*
|
||||
* @param {object} [io] - The seam above, plus `install` and `fail`.
|
||||
* @returns {{apply: string|null, fallback: boolean, stopped?: boolean}} The plan.
|
||||
*/
|
||||
export function runPinokioUpdate(io = {}) {
|
||||
const {
|
||||
install = installPinokioDependencies,
|
||||
fail = (code) => process.exit(code),
|
||||
} = io;
|
||||
const plan = updateFromRemote(io);
|
||||
if (plan.stopped) {
|
||||
// Nothing could be shown, so nothing was applied. Reinstalling now would
|
||||
// run install scripts under the banner of an update that did not happen,
|
||||
// and report success for it.
|
||||
fail(1);
|
||||
return plan;
|
||||
}
|
||||
install();
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Guarded like pinokio-start.mjs so the report above can be exercised without
|
||||
// pulling and reinstalling as an import side effect.
|
||||
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
|
||||
runPinokioUpdate();
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
planeSupportPoints,
|
||||
poseHash,
|
||||
SUPPORT_KEYS,
|
||||
} from '../src/data/cctvFootprint.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
||||
const SAMPLE_KEYS = ['mount', ...SUPPORT_KEYS];
|
||||
const BATCH_SIZES = [16, 4, 1];
|
||||
const DEADLINE_MS = 90_000;
|
||||
|
||||
/** Extract the served nominal pose (the values the catalog ships). */
|
||||
export function nominalPose(source) {
|
||||
const pose = Object.fromEntries(
|
||||
[
|
||||
'lat',
|
||||
'lon',
|
||||
'headingDeg',
|
||||
'pitchDeg',
|
||||
'fovDeg',
|
||||
'rangeM',
|
||||
'mountHeightM',
|
||||
].map((key) => {
|
||||
if (typeof source[key] !== 'number' || !Number.isFinite(source[key])) {
|
||||
throw new Error(`Camera ${source.id}: invalid ${key}`);
|
||||
}
|
||||
return [key, source[key]];
|
||||
}),
|
||||
);
|
||||
if (Math.abs(pose.lat) > 90 || Math.abs(pose.lon) > 180) {
|
||||
throw new Error(`Camera ${source.id}: invalid coordinates`);
|
||||
}
|
||||
// Hashed exactly as served: the server join (groundHeights.js) and the
|
||||
// client (camera.servedPose) hash the same raw values, so no clamp here.
|
||||
return pose;
|
||||
}
|
||||
|
||||
/** Return a roughly 2 km grid key, with longitude width fixed per row. */
|
||||
export function cellKey({ lat, lon }) {
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
throw new Error('Invalid cell coordinates');
|
||||
}
|
||||
const row = Math.min(8999, Math.floor((lat + 90) / 0.02));
|
||||
const rowLat = -90 + (row + 0.5) * 0.02;
|
||||
const lonWidth = 0.02 / Math.cos((rowLat * Math.PI) / 180);
|
||||
const col = Math.floor(((lon === 180 ? -180 : lon) + 180) / lonWidth);
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
/** Split an ordered array into bounded batches without mutating it. */
|
||||
export function batches(items, size = 16) {
|
||||
if (!Number.isSafeInteger(size) || size < 1)
|
||||
throw new Error('Invalid batch size');
|
||||
const result = [];
|
||||
for (let i = 0; i < items.length; i += size)
|
||||
result.push(items.slice(i, i + size));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Group cameras by cell and order cells numerically by row, then column. */
|
||||
export function planCells(cameras) {
|
||||
const cells = new Map();
|
||||
for (const camera of cameras) {
|
||||
const key = cellKey(camera.pose);
|
||||
if (!cells.has(key)) cells.set(key, { key, cameras: [] });
|
||||
cells.get(key).cameras.push(camera);
|
||||
}
|
||||
return [...cells.values()].sort((a, b) => {
|
||||
const [ar, ac] = a.key.split(',').map(Number);
|
||||
const [br, bc] = b.key.split(',').map(Number);
|
||||
return ar - br || ac - bc;
|
||||
});
|
||||
}
|
||||
|
||||
/** Preserve ellipsoidal metres; missing, non-finite and implausible values are null. */
|
||||
export function plausibleHeight(height) {
|
||||
return Number.isFinite(height) && height >= -150 ? height : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a complete, successful entry for exactly the current nominal pose is
|
||||
* reusable; an entry with missing supports is resampled (valid heights are
|
||||
* kept by mergeCameraSamples).
|
||||
*/
|
||||
export function needsSampling(entry, pose) {
|
||||
return (
|
||||
entry?.status !== 'ok' ||
|
||||
entry.poseHash !== poseHash(pose) ||
|
||||
(Array.isArray(entry.misses) && entry.misses.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Merge retry samples without losing valid heights; a missing mount stores no heights. */
|
||||
export function mergeCameraSamples(
|
||||
pose,
|
||||
heights,
|
||||
previous,
|
||||
attempts,
|
||||
sampledAt,
|
||||
) {
|
||||
const hash = poseHash(pose);
|
||||
const prior = previous?.poseHash === hash ? previous : null;
|
||||
const values = SAMPLE_KEYS.map(
|
||||
(key, i) =>
|
||||
plausibleHeight(heights[i]) ??
|
||||
plausibleHeight(
|
||||
key === 'mount' ? prior?.mountGroundM : prior?.supports?.[key],
|
||||
),
|
||||
);
|
||||
const entry = {
|
||||
poseHash: hash,
|
||||
status: values[0] === null ? 'miss' : 'ok',
|
||||
misses: SAMPLE_KEYS.filter((_, i) => values[i] === null),
|
||||
sampledAt,
|
||||
attempts,
|
||||
};
|
||||
if (entry.status === 'ok') {
|
||||
entry.mountGroundM = values[0];
|
||||
entry.supports = Object.fromEntries(
|
||||
SUPPORT_KEYS.map((key, i) => [key, values[i + 1]]),
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Build a sidecar while retaining entries outside this run's selection. */
|
||||
export function mergeSidecar(existing, updates, generatedAt) {
|
||||
const cameras = { ...existing?.cameras, ...updates };
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
provider: 'google-3d-tiles',
|
||||
heightReference: 'WGS84-ellipsoid',
|
||||
generatedAt,
|
||||
cameras: Object.fromEntries(
|
||||
Object.keys(cameras)
|
||||
.sort()
|
||||
.map((id) => [id, cameras[id]]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse pilot filters; the limit applies after kinds, before resume filtering. */
|
||||
export function parseArgs(argv) {
|
||||
const options = { limit: Infinity, kinds: null, dryRun: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--limit') {
|
||||
const value = argv[++i];
|
||||
if (!/^\d+$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) {
|
||||
throw new Error('--limit requires a non-negative integer');
|
||||
}
|
||||
options.limit = Number(value);
|
||||
} else if (arg === '--kinds') {
|
||||
const value = argv[++i];
|
||||
if (
|
||||
!value ||
|
||||
value.startsWith('--') ||
|
||||
value.split(',').some((kind) => !kind.trim())
|
||||
) {
|
||||
throw new Error('--kinds requires comma-separated sourceKinds');
|
||||
}
|
||||
options.kinds = value.split(',').map((kind) => kind.trim());
|
||||
} else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Use the shared footprint's mount and nine supports in the sampling wire order. */
|
||||
export function samplingPoints(pose) {
|
||||
const footprint = planeSupportPoints(pose);
|
||||
return SAMPLE_KEYS.map((key) => {
|
||||
const point = key === 'mount' ? footprint.mount : footprint.supports[key];
|
||||
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon)) {
|
||||
throw new Error(
|
||||
`Shared planeSupportPoints returned invalid coordinates for ${key}`,
|
||||
);
|
||||
}
|
||||
return { lat: point.lat, lon: point.lon };
|
||||
});
|
||||
}
|
||||
|
||||
/** Sample one cell serially, retrying failed cameras at most three times (16/4/1). */
|
||||
export async function sampleCell(
|
||||
cameras,
|
||||
sampler,
|
||||
now = () => new Date().toISOString(),
|
||||
existing = null,
|
||||
) {
|
||||
// Seed with the prior entries so a resumed camera keeps every valid height
|
||||
// it already has (mergeCameraSamples only replaces misses).
|
||||
const entries = Object.create(null);
|
||||
for (const camera of cameras) {
|
||||
if (existing?.[camera.id]) entries[camera.id] = existing[camera.id];
|
||||
}
|
||||
let pending = cameras;
|
||||
for (const [index, size] of BATCH_SIZES.entries()) {
|
||||
const retry = [];
|
||||
for (const batch of batches(pending, size)) {
|
||||
let samples;
|
||||
try {
|
||||
samples = await sampler.sample(batch);
|
||||
} catch (error) {
|
||||
sampler.warn?.(
|
||||
`Attempt ${index + 1}, ${batch.length} cameras: ${error.message}`,
|
||||
);
|
||||
// A timed-out evaluate can still be sampling: destroy its page before retrying.
|
||||
await sampler.recycle();
|
||||
samples = batch.map(() => []);
|
||||
}
|
||||
const sampledAt = now();
|
||||
for (let i = 0; i < batch.length; i += 1) {
|
||||
const camera = batch[i];
|
||||
const entry = mergeCameraSamples(
|
||||
camera.pose,
|
||||
samples[i] ?? [],
|
||||
entries[camera.id],
|
||||
index + 1,
|
||||
sampledAt,
|
||||
);
|
||||
entries[camera.id] = entry;
|
||||
if (entry.misses.length) retry.push(camera);
|
||||
}
|
||||
}
|
||||
pending = retry;
|
||||
if (!pending.length) break;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Bound a sampling operation; callers must recycle its page after rejection. */
|
||||
export async function withDeadline(operation, timeoutMs = DEADLINE_MS) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Height sampling timed out after ${timeoutMs / 1000} s`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(browser, base) {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(
|
||||
`${base}/#v=2&lat=30&lon=-95&alt=17000000&heading=0&pitch=-90&roll=0`,
|
||||
);
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer);
|
||||
return page;
|
||||
} catch (error) {
|
||||
await page.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSidecar(output) {
|
||||
let existing;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(output, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
existing?.schemaVersion !== 1 ||
|
||||
existing.provider !== 'google-3d-tiles' ||
|
||||
existing.heightReference !== 'WGS84-ellipsoid' ||
|
||||
!existing.cameras ||
|
||||
typeof existing.cameras !== 'object' ||
|
||||
Array.isArray(existing.cameras)
|
||||
) {
|
||||
throw new Error(`Incompatible sidecar: ${output}`);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function writeSidecar(output, sidecar) {
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
const temporary = `${output}.${process.pid}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, `${JSON.stringify(sidecar, null, 2)}\n`);
|
||||
await rename(temporary, output);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function counts(cameras, entries) {
|
||||
const ok = cameras.filter(
|
||||
(camera) => entries[camera.id]?.status === 'ok',
|
||||
).length;
|
||||
return { ok, miss: cameras.length - ok };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const started = Date.now();
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const base = (process.env.GEV_BASE || 'http://localhost:4173').replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const output = path.resolve(
|
||||
ROOT,
|
||||
process.env.OUT ||
|
||||
'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json',
|
||||
);
|
||||
let sidecar = await loadSidecar(output);
|
||||
const response = await fetch(`${base}/api/cctv/sources`, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Catalog HTTP ${response.status}`);
|
||||
const catalog = await response.json();
|
||||
if (!Array.isArray(catalog.sources))
|
||||
throw new Error('Catalog must contain sources[]');
|
||||
const ids = new Set();
|
||||
const selected = catalog.sources
|
||||
.filter(
|
||||
(source) => !options.kinds || options.kinds.includes(source.sourceKind),
|
||||
)
|
||||
.slice(0, options.limit)
|
||||
.flatMap((source) => {
|
||||
if (typeof source.id !== 'string' || !source.id || ids.has(source.id)) {
|
||||
throw new Error('Catalog has an invalid or duplicate camera id');
|
||||
}
|
||||
ids.add(source.id);
|
||||
let pose;
|
||||
try {
|
||||
pose = nominalPose(source);
|
||||
} catch (error) {
|
||||
// One camera with a malformed pose must not abort the whole run.
|
||||
console.warn(`skip ${source.id}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: source.id,
|
||||
sourceKind: source.sourceKind || 'unknown',
|
||||
pose,
|
||||
},
|
||||
];
|
||||
});
|
||||
const pending = selected.filter((camera) =>
|
||||
needsSampling(sidecar?.cameras?.[camera.id], camera.pose),
|
||||
);
|
||||
const cells = planCells(pending);
|
||||
console.log(
|
||||
`${selected.length} cameras selected, ${selected.length - pending.length} resumed, ${pending.length} to sample in ${cells.length} cells`,
|
||||
);
|
||||
if (options.dryRun) {
|
||||
for (const cell of cells) {
|
||||
const sizes = batches(cell.cameras).map(
|
||||
(batch) => `${batch.length} cameras/${batch.length * 10} points`,
|
||||
);
|
||||
console.log(
|
||||
`cell ${cell.key}: ${cell.cameras.length} cameras; batches: ${sizes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
'Retries: failed cameras in batches of 4, then singly; 3 attempts, 90 s per call.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate shared geometry before starting an expensive browser session.
|
||||
for (const camera of pending) camera.points = samplingPoints(camera.pose);
|
||||
let browser;
|
||||
try {
|
||||
if (pending.length) {
|
||||
const { default: puppeteer } = await import('puppeteer');
|
||||
browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
protocolTimeout: 600000,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1200,800',
|
||||
],
|
||||
});
|
||||
let page = await openPage(browser, base);
|
||||
const sampler = {
|
||||
warn: (message) => console.error(message),
|
||||
recycle: async () => {
|
||||
await page.close();
|
||||
page = await openPage(browser, base);
|
||||
},
|
||||
sample: (batch) =>
|
||||
withDeadline(
|
||||
page.evaluate(async (cameras) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
const Cartesian3 = gev.viewer.camera.position.constructor;
|
||||
const Cartographic =
|
||||
gev.viewer.camera.positionCartographic.constructor;
|
||||
const lat =
|
||||
cameras.reduce((sum, camera) => sum + camera.pose.lat, 0) /
|
||||
cameras.length;
|
||||
const lon =
|
||||
cameras.reduce((sum, camera) => sum + camera.pose.lon, 0) /
|
||||
cameras.length;
|
||||
gev.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(lon, lat, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
const cartographics = cameras.flatMap((camera) =>
|
||||
camera.points.map((point) =>
|
||||
Cartographic.fromDegrees(point.lon, point.lat),
|
||||
),
|
||||
);
|
||||
const sampled =
|
||||
await scene.sampleHeightMostDetailed(cartographics);
|
||||
return cameras.map((_, i) =>
|
||||
sampled
|
||||
.slice(i * 10, (i + 1) * 10)
|
||||
.map((point) =>
|
||||
Number.isFinite(point.height) ? point.height : null,
|
||||
),
|
||||
);
|
||||
}, batch),
|
||||
),
|
||||
};
|
||||
for (const cell of cells) {
|
||||
const cellStarted = Date.now();
|
||||
const entries = await sampleCell(
|
||||
cell.cameras,
|
||||
sampler,
|
||||
undefined,
|
||||
sidecar?.cameras,
|
||||
);
|
||||
sidecar = mergeSidecar(sidecar, entries, new Date().toISOString());
|
||||
await writeSidecar(output, sidecar);
|
||||
const tally = counts(cell.cameras, entries);
|
||||
console.log(
|
||||
`cell ${cell.key}: ${cell.cameras.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - cellStarted) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
const entries = sidecar?.cameras || {};
|
||||
const tally = counts(selected, entries);
|
||||
console.log(
|
||||
`Total: ${selected.length} cameras, ${tally.ok} ok/${tally.miss} miss, ${((Date.now() - started) / 1000).toFixed(1)} s`,
|
||||
);
|
||||
for (const kind of [
|
||||
...new Set(selected.map((camera) => camera.sourceKind)),
|
||||
].sort()) {
|
||||
const tally = counts(
|
||||
selected.filter((camera) => camera.sourceKind === kind),
|
||||
entries,
|
||||
);
|
||||
console.log(` ${kind}: ${tally.ok} ok/${tally.miss} miss`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Resolve the explicit CLI project directory, defaulting to the tool's repository. */
|
||||
export function projectRoot(moduleUrl, environment = process.env) {
|
||||
return path.resolve(
|
||||
environment.GEV_PROJECT_ROOT ||
|
||||
path.join(path.dirname(fileURLToPath(moduleUrl)), '..'),
|
||||
);
|
||||
}
|
||||
+814
-323
File diff suppressed because it is too large.
Load diff
@@ -2171,7 +2171,8 @@ try {
|
||||
);
|
||||
|
||||
const resetResult = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
// Both the UI and compatibility facade delegate to this navigation owner.
|
||||
const manager = window.__godsEyeView.styleManager._locationNavigation;
|
||||
const awareness = window.__godsEyeView.dataManager.layers.get('military-awareness')?.module;
|
||||
window.__qaCockpitReset = {
|
||||
calls: 0,
|
||||
@@ -2203,7 +2204,7 @@ try {
|
||||
const awareness = gev.dataManager.layers.get('military-awareness')?.module;
|
||||
const subjectId = awareness?.getContextSnapshot?.()?.subject?.id || null;
|
||||
const height = gev.viewer.camera.positionCartographic?.height;
|
||||
gev.styleManager.resetToGlobeView = qa.original;
|
||||
gev.styleManager._locationNavigation.resetToGlobeView = qa.original;
|
||||
delete window.__qaCockpitReset;
|
||||
return {
|
||||
calls: qa.calls,
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser proof of the Directions layer: real chip clicks, real globe clicks,
|
||||
* a real route from the real proxy, a real camera flight.
|
||||
*
|
||||
* Usage: node scripts/qa-directions.mjs [--url http://localhost:4173]
|
||||
* [--shots qa-shots/directions] [--label keyed] [--skip denver]
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
};
|
||||
const BASE = arg('url', 'http://localhost:4173');
|
||||
const SHOTS = arg('shots', path.join(ROOT, 'qa-shots', 'directions'));
|
||||
const LABEL = arg('label', 'keyed');
|
||||
const SKIP = new Set((arg('skip', '') || '').split(',').filter(Boolean));
|
||||
|
||||
await fs.mkdir(SHOTS, { recursive: true });
|
||||
|
||||
let failures = 0;
|
||||
const lines = [];
|
||||
const check = (name, passed, detail = '') => {
|
||||
const text = `[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`;
|
||||
console.log(text);
|
||||
lines.push(text);
|
||||
if (!passed) failures += 1;
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,860',
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
const consoleErrors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const shot = async (name) => {
|
||||
const file = path.join(SHOTS, name);
|
||||
await page.screenshot({ path: file, type: 'jpeg', quality: 82 });
|
||||
console.log(` shot: ${file}`);
|
||||
};
|
||||
|
||||
/** Put the camera over a place, instantly, and let the scene settle. */
|
||||
async function lookAt(lat, lon, height) {
|
||||
await page.evaluate(
|
||||
(la, lo, h) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(lo, la, h),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2.2, roll: 0 },
|
||||
});
|
||||
},
|
||||
lat,
|
||||
lon,
|
||||
height,
|
||||
);
|
||||
}
|
||||
|
||||
/** Canvas position of a lon/lat on the rendered surface, or null. */
|
||||
const screenAt = (lat, lon) =>
|
||||
page.evaluate(
|
||||
(la, lo) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const Cartesian3 = viewer.camera.positionWC.constructor;
|
||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
||||
const carto = Cartographic.fromDegrees(lo, la);
|
||||
let ground = scene.globe?.getHeight?.(carto);
|
||||
if (!Number.isFinite(ground)) ground = 0;
|
||||
const world = Cartesian3.fromDegrees(lo, la, ground);
|
||||
const win = scene.cartesianToCanvasCoordinates(world);
|
||||
if (!win || !Number.isFinite(win.x)) return null;
|
||||
const x = Math.round(win.x);
|
||||
const y = Math.round(win.y);
|
||||
if (x < 40 || y < 40 || x > 1240 || y > 820) return null;
|
||||
return { x, y };
|
||||
},
|
||||
lat,
|
||||
lon,
|
||||
);
|
||||
|
||||
const layerState = () =>
|
||||
page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const manager = gev.dataManager;
|
||||
const module = manager.layers.get('directions').module;
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
const entities = [...viewer.entities.values];
|
||||
const routeEntities = entities.filter(
|
||||
(entity) => String(entity.id) === 'directions:route',
|
||||
);
|
||||
const listNodes = row
|
||||
? [...row.querySelectorAll('.data-row-list-item')]
|
||||
: [];
|
||||
return {
|
||||
stats: module.getStats(),
|
||||
chips: controls.chips.map((chip) => ({
|
||||
id: chip.id,
|
||||
label: chip.label,
|
||||
active: !!chip.active,
|
||||
disabled: !!chip.disabled,
|
||||
})),
|
||||
listCount: controls.list?.items?.length || 0,
|
||||
listText: (controls.list?.items || []).map((item) => item.text),
|
||||
domList: listNodes.map((node) => node.textContent),
|
||||
domAllButtons: listNodes.every((node) => node.tagName === 'BUTTON'),
|
||||
domInOl:
|
||||
listNodes.length > 0 &&
|
||||
listNodes[0].closest('ol')?.classList.contains('data-row-list'),
|
||||
domCurrent: listNodes.findIndex((node) =>
|
||||
node.classList.contains('current'),
|
||||
),
|
||||
routeEntities: routeEntities.length,
|
||||
routePositions:
|
||||
routeEntities[0]?.polyline?.positions?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
)?.length || 0,
|
||||
markerCount: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:marker:'),
|
||||
).length,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
||||
};
|
||||
});
|
||||
|
||||
const clickChip = async (chipId) => {
|
||||
const clicked = await page.evaluate((id) => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector(`button[data-chip-id="${id}"]`);
|
||||
if (!button || button.disabled) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}, chipId);
|
||||
await sleep(150);
|
||||
return clicked;
|
||||
};
|
||||
|
||||
const setMode = async (mode) => {
|
||||
await clickChip(`mode-${mode}`);
|
||||
};
|
||||
|
||||
const waitForRoute = () =>
|
||||
page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('directions')
|
||||
.module.getStats().count > 0,
|
||||
{ timeout: 40000 },
|
||||
);
|
||||
|
||||
const waitForMode = (word) =>
|
||||
page.waitForFunction(
|
||||
(w) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('directions')
|
||||
.module.getStats()
|
||||
.coverage?.endsWith(w),
|
||||
{ timeout: 40000 },
|
||||
word,
|
||||
);
|
||||
|
||||
const cameraPose = () =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionWC;
|
||||
return { x: c.x, y: c.y, z: c.z };
|
||||
});
|
||||
const poseDelta = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
|
||||
/** Place A and B by clicking the globe. Returns whether both landed. */
|
||||
async function placeEndpoints(a, b) {
|
||||
await clickChip('set-a');
|
||||
const atA = await screenAt(a.lat, a.lon);
|
||||
if (!atA) return false;
|
||||
await page.mouse.click(atA.x, atA.y);
|
||||
await sleep(700);
|
||||
await clickChip('set-b');
|
||||
const atB = await screenAt(b.lat, b.lon);
|
||||
if (!atB) return false;
|
||||
await page.mouse.click(atB.x, atB.y);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(`${BASE}/?welcome=0`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 90000,
|
||||
});
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: 90000,
|
||||
});
|
||||
// Two module reads the assertions need. Both are the app's OWN module
|
||||
// instances (Vite serves one copy), so this observes rather than simulates.
|
||||
await page.evaluate(async () => {
|
||||
const [own, verbs] = await Promise.all([
|
||||
import('/src/data/inputOwnership.js'),
|
||||
import('/src/cameraVerbs.js'),
|
||||
]);
|
||||
window.__gevQa = {
|
||||
pointerOwner: own.pointerOwner,
|
||||
claimPointer: own.claimPointer,
|
||||
releasePointer: own.releasePointer,
|
||||
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
||||
};
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
// ── 1. Open the DATA LAYERS panel, then toggle the row on. ──────────────
|
||||
const opened = await page.evaluate(() => {
|
||||
const panel = document.getElementById('data-panel');
|
||||
if (!panel) return false;
|
||||
if (panel.classList.contains('collapsed')) {
|
||||
panel
|
||||
.querySelector('.panel-collapse-btn[data-collapse-target="data-panel"]')
|
||||
?.click();
|
||||
}
|
||||
return !document
|
||||
.getElementById('data-panel')
|
||||
.classList.contains('collapsed');
|
||||
});
|
||||
await sleep(700);
|
||||
check('the DATA LAYERS panel opens from its own header control', opened);
|
||||
const toggled = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const button = row?.querySelector('.data-toggle-btn');
|
||||
if (!button) return false;
|
||||
button.scrollIntoView({ block: 'center' });
|
||||
button.click();
|
||||
return true;
|
||||
});
|
||||
await sleep(1200);
|
||||
check(
|
||||
'the Directions row is in the Data Layers panel and toggles on',
|
||||
toggled,
|
||||
);
|
||||
let state = await layerState();
|
||||
check(
|
||||
'the row guides the operator before anything is placed',
|
||||
/SET A/.test(state.stats.coverage || ''),
|
||||
state.stats.coverage,
|
||||
);
|
||||
|
||||
// ── 1b. The routing service's attribution is on screen, with the link its
|
||||
// usage policy asks for. ────────────────────────────────────────────
|
||||
const credit = await page.evaluate(async () => {
|
||||
const anchor = [...document.querySelectorAll('a')].find((a) =>
|
||||
/data attribution/i.test(a.textContent),
|
||||
);
|
||||
anchor?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
const text = document.body.innerText;
|
||||
const html = document.body.innerHTML;
|
||||
const open = /Data provided by/i.test(text);
|
||||
const entry = [...document.querySelectorAll('li')].find((node) =>
|
||||
/OSRM on the FOSSGIS servers/i.test(node.textContent),
|
||||
);
|
||||
entry?.scrollIntoView({ block: 'center' });
|
||||
return {
|
||||
open,
|
||||
routing: /OSRM on the FOSSGIS servers/i.test(text),
|
||||
osm: /OpenStreetMap contributors/i.test(text),
|
||||
fixTheMap: /openstreetmap\.org\/fixthemap/.test(html),
|
||||
};
|
||||
});
|
||||
check('the Data attribution popover opens', credit.open);
|
||||
check('it credits the routing service', credit.routing);
|
||||
check('it credits OpenStreetMap contributors', credit.osm);
|
||||
check(
|
||||
'it carries the "fix the map" link the usage policy asks for',
|
||||
credit.fixTheMap,
|
||||
);
|
||||
await shot(`04-${LABEL}-attribution.jpg`);
|
||||
await page.evaluate(() => {
|
||||
const close = [...document.querySelectorAll('a, button')].find(
|
||||
(node) => node.className && /close/i.test(String(node.className)),
|
||||
);
|
||||
close?.click();
|
||||
});
|
||||
await sleep(500);
|
||||
|
||||
// ── 2. Place A on the Ferry Building, B in Golden Gate Park. ─────────────
|
||||
const FERRY = { lat: 37.7955, lon: -122.3937 };
|
||||
const GGPARK = { lat: 37.7694, lon: -122.4862 };
|
||||
await lookAt(37.782, -122.44, 16000);
|
||||
await sleep(5000);
|
||||
|
||||
await clickChip('set-a');
|
||||
state = await layerState();
|
||||
check(
|
||||
'arming SET A claims the shared pointer',
|
||||
state.pointerOwner === 'directions',
|
||||
`owner=${state.pointerOwner}`,
|
||||
);
|
||||
check(
|
||||
'the armed chip tells the operator to click the map',
|
||||
state.chips.find((chip) => chip.id === 'set-a')?.label === 'CLICK MAP',
|
||||
);
|
||||
|
||||
let at = await screenAt(FERRY.lat, FERRY.lon);
|
||||
check('the Ferry Building is on screen', !!at, JSON.stringify(at));
|
||||
await page.mouse.click(at.x, at.y);
|
||||
await sleep(800);
|
||||
state = await layerState();
|
||||
check(
|
||||
'placing A releases the pointer claim',
|
||||
state.pointerOwner === null,
|
||||
`owner=${state.pointerOwner}`,
|
||||
);
|
||||
check('A is marked on the globe', state.markerCount === 1);
|
||||
|
||||
// A handler bound to the canvas AFTER this layer's own sees the same click.
|
||||
// That is every ambient selection handler in the app, and it is the one that
|
||||
// used to place B and deselect whatever was under it in the same gesture.
|
||||
await page.evaluate(() => {
|
||||
window.__gevQaLateHandler = [];
|
||||
const canvas = window.__godsEyeView.viewer.scene.canvas;
|
||||
canvas.addEventListener('pointerup', () => {
|
||||
window.__gevQaLateHandler.push(window.__gevQa.pointerOwner());
|
||||
});
|
||||
});
|
||||
|
||||
await clickChip('set-b');
|
||||
at = await screenAt(GGPARK.lat, GGPARK.lon);
|
||||
check('Golden Gate Park is on screen', !!at, JSON.stringify(at));
|
||||
await page.mouse.click(at.x, at.y);
|
||||
const lateOwners = await page.evaluate(() => window.__gevQaLateHandler);
|
||||
check(
|
||||
'a handler later in the same click still sees the pointer as taken',
|
||||
lateOwners.length > 0 &&
|
||||
lateOwners.every((owner) => owner === 'directions'),
|
||||
`saw ${JSON.stringify(lateOwners)}`,
|
||||
);
|
||||
await waitForRoute();
|
||||
await sleep(2000);
|
||||
state = await layerState();
|
||||
check(
|
||||
'once that click is over, the pointer claim is returned',
|
||||
state.pointerOwner === null,
|
||||
`owner=${state.pointerOwner}`,
|
||||
);
|
||||
check('a route polyline is drawn', state.routeEntities === 1);
|
||||
check(
|
||||
'the route follows streets rather than a straight line',
|
||||
state.routePositions >= 50,
|
||||
`${state.routePositions} vertices`,
|
||||
);
|
||||
check(
|
||||
'the turn-by-turn list has at least 10 steps',
|
||||
state.listCount >= 10,
|
||||
`${state.listCount} steps`,
|
||||
);
|
||||
check(
|
||||
'the list renders in the panel as an ordered list of focusable buttons',
|
||||
state.domList.length === state.listCount &&
|
||||
state.domAllButtons &&
|
||||
state.domInOl,
|
||||
`${state.domList.length} rendered, ol=${state.domInOl}, buttons=${state.domAllButtons}`,
|
||||
);
|
||||
check(
|
||||
'the rendered list is in route order',
|
||||
state.domList.every((text, index) => text.includes(state.listText[index])),
|
||||
);
|
||||
const driveSummary = state.stats.coverage;
|
||||
check(
|
||||
'the row summarises distance, time and mode',
|
||||
/·.*·\s*Drive$/.test(driveSummary || ''),
|
||||
driveSummary,
|
||||
);
|
||||
const focused = await page.evaluate(() => {
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const items = row.querySelectorAll('.data-row-list-item');
|
||||
items[2]?.focus();
|
||||
const active = document.activeElement === items[2];
|
||||
items[2]?.click();
|
||||
return { active, tabIndex: items[2]?.tabIndex };
|
||||
});
|
||||
await sleep(600);
|
||||
const afterListClick = await layerState();
|
||||
check(
|
||||
'a list row takes keyboard focus and selects its maneuver',
|
||||
focused.active && afterListClick.stats.count > 0,
|
||||
`focused=${focused.active}`,
|
||||
);
|
||||
await shot(`01-${LABEL}-sf-route-and-list.jpg`);
|
||||
|
||||
// ── 3. Switch profile; the route changes. ────────────────────────────────
|
||||
await setMode('foot');
|
||||
await waitForMode('Walk');
|
||||
const walk = await layerState();
|
||||
check(
|
||||
'WALK reroutes and the summary follows',
|
||||
walk.stats.coverage !== driveSummary,
|
||||
walk.stats.coverage,
|
||||
);
|
||||
check(
|
||||
'the walking route is its own route',
|
||||
walk.listCount !== state.listCount || walk.stats.coverage !== driveSummary,
|
||||
`drive ${state.listCount} steps vs walk ${walk.listCount}`,
|
||||
);
|
||||
await setMode('bike');
|
||||
await waitForMode('Bike');
|
||||
const bike = await layerState();
|
||||
check(
|
||||
'BIKE reroutes as well',
|
||||
/Bike$/.test(bike.stats.coverage),
|
||||
bike.stats.coverage,
|
||||
);
|
||||
await setMode('car');
|
||||
await waitForMode('Drive');
|
||||
|
||||
// ── 4. Swap endpoints. ──────────────────────────────────────────────────
|
||||
const beforeSwap = await layerState();
|
||||
await clickChip('swap');
|
||||
await sleep(3500);
|
||||
const afterSwap = await layerState();
|
||||
check(
|
||||
'SWAP reverses the route',
|
||||
afterSwap.routeEntities === 1 &&
|
||||
afterSwap.listText[0] !== beforeSwap.listText[0],
|
||||
`"${beforeSwap.listText[0]}" -> "${afterSwap.listText[0]}"`,
|
||||
);
|
||||
await clickChip('swap');
|
||||
await sleep(3500);
|
||||
|
||||
// ── 5. Three rapid reroutes leave exactly one route. ─────────────────────
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
manager.setLayerParams('directions', { mode: 'foot' }, { origin: 'user' });
|
||||
manager.setLayerParams('directions', { mode: 'bike' }, { origin: 'user' });
|
||||
manager.setLayerParams('directions', { mode: 'car' }, { origin: 'user' });
|
||||
});
|
||||
await sleep(6000);
|
||||
const rapid = await layerState();
|
||||
check(
|
||||
'three rapid reroutes leave exactly one route',
|
||||
rapid.routeEntities === 1,
|
||||
`${rapid.routeEntities} route entities`,
|
||||
);
|
||||
check(
|
||||
'the surviving route is the last one asked for',
|
||||
/Drive$/.test(rapid.stats.coverage || ''),
|
||||
rapid.stats.coverage,
|
||||
);
|
||||
|
||||
// ── 6. What the row says for each shape the proxy can answer with.
|
||||
//
|
||||
// These stub the proxy's RESPONSE, so they test this layer's handling of it
|
||||
// and nothing about the proxy itself. What the proxy actually answers for a
|
||||
// routing-service rate limit, a redirect, an over-long route and a full
|
||||
// outbound queue is covered where it can be observed — the provider tests in
|
||||
// src/tooling/placeProviders.test.mjs. The bodies below are the ones those
|
||||
// tests pin, so the two halves meet. ─────────────────────────────────────
|
||||
const cdp = await page.target().createCDPSession();
|
||||
let stub = null;
|
||||
cdp.on('Fetch.requestPaused', async ({ requestId }) => {
|
||||
if (!stub) {
|
||||
await cdp.send('Fetch.continueRequest', { requestId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (stub.hold) return; // never answer: the client's own timeout must fire
|
||||
await cdp
|
||||
.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: stub.status,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
...(stub.status === 429 ? [{ name: 'Retry-After', value: '5' }] : []),
|
||||
],
|
||||
body: Buffer.from(stub.body).toString('base64'),
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
await cdp.send('Fetch.enable', {
|
||||
patterns: [{ urlPattern: '*/api/route*', requestStage: 'Request' }],
|
||||
});
|
||||
|
||||
const reroute = async () => {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(
|
||||
'directions',
|
||||
{ mode: 'foot' },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
);
|
||||
};
|
||||
const failCases = [
|
||||
{
|
||||
name: 'no route found',
|
||||
stub: { status: 200, body: '{"ok":false,"error":"no route found"}' },
|
||||
wait: 3500,
|
||||
expect: /No route found between A and B/,
|
||||
},
|
||||
{
|
||||
// The exact body the proxy sends when the routing service rate-limits
|
||||
// US (pinned by the provider test of the same name).
|
||||
name: 'the routing service rate-limited us (429)',
|
||||
stub: {
|
||||
status: 429,
|
||||
body: '{"ok":false,"error":"routing service is rate limited"}',
|
||||
},
|
||||
wait: 3500,
|
||||
expect: /routing service is rate limited/i,
|
||||
},
|
||||
{
|
||||
// And when our own outbound queue is full.
|
||||
name: 'our own outbound queue is full (429)',
|
||||
stub: {
|
||||
status: 429,
|
||||
body: '{"ok":false,"error":"routing busy — too many routes at once"}',
|
||||
},
|
||||
wait: 3500,
|
||||
expect: /routing busy/i,
|
||||
},
|
||||
{
|
||||
name: 'upstream never answers (timeout)',
|
||||
stub: { hold: true },
|
||||
wait: 19000,
|
||||
expect: /Routing timed out/,
|
||||
},
|
||||
];
|
||||
for (const failCase of failCases) {
|
||||
stub = failCase.stub;
|
||||
await reroute();
|
||||
await sleep(failCase.wait);
|
||||
const failed = await layerState();
|
||||
check(
|
||||
`the row reports "${failCase.name}" honestly`,
|
||||
failCase.expect.test(failed.stats.error || ''),
|
||||
failed.stats.error,
|
||||
);
|
||||
check(
|
||||
`"${failCase.name}" leaves no straight line drawn as a route`,
|
||||
failed.routeEntities === 0,
|
||||
);
|
||||
// Back to a real route before the next case.
|
||||
stub = null;
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(
|
||||
'directions',
|
||||
{ mode: 'car' },
|
||||
{ origin: 'user' },
|
||||
),
|
||||
);
|
||||
await waitForRoute();
|
||||
await sleep(1200);
|
||||
}
|
||||
await cdp.send('Fetch.disable').catch(() => {});
|
||||
|
||||
// ── 7. FLY, a mid-flight frame, and a cancel that actually stops it. ─────
|
||||
const beforeFly = await cameraPose();
|
||||
check('the FLY chip is offered once a route exists', await clickChip('fly'));
|
||||
await sleep(3000);
|
||||
const flying = await layerState();
|
||||
const midFly = await cameraPose();
|
||||
check(
|
||||
'FLY starts a route flight',
|
||||
flying.cameraMotion?.kind === 'route',
|
||||
JSON.stringify(flying.cameraMotion),
|
||||
);
|
||||
check(
|
||||
'the camera actually moved',
|
||||
poseDelta(beforeFly, midFly) > 50,
|
||||
`${Math.round(poseDelta(beforeFly, midFly))} m`,
|
||||
);
|
||||
check(
|
||||
'the list highlights the step being flown',
|
||||
flying.domCurrent >= 0,
|
||||
`dom index ${flying.domCurrent}`,
|
||||
);
|
||||
await shot(`02-${LABEL}-mid-flight.jpg`);
|
||||
|
||||
// A reroute replaces the route the dolly is flying; the dolly has to land
|
||||
// first rather than keep flying a route that no longer exists.
|
||||
await setMode('foot');
|
||||
await sleep(1200);
|
||||
const afterReroute = await layerState();
|
||||
check(
|
||||
'changing profile mid-flight lands the flight it replaced',
|
||||
afterReroute.cameraMotion === null,
|
||||
JSON.stringify(afterReroute.cameraMotion),
|
||||
);
|
||||
await waitForMode('Walk');
|
||||
await setMode('car');
|
||||
await waitForMode('Drive');
|
||||
await sleep(800);
|
||||
check('FLY is offered again after the reroute', await clickChip('fly'));
|
||||
await sleep(2000);
|
||||
|
||||
await clickChip('clear');
|
||||
await sleep(500);
|
||||
const stopped = await cameraPose();
|
||||
await sleep(3000);
|
||||
const later = await cameraPose();
|
||||
const afterClear = await layerState();
|
||||
check(
|
||||
'CLEAR mid-flight stops the flight it started',
|
||||
afterClear.cameraMotion === null,
|
||||
JSON.stringify(afterClear.cameraMotion),
|
||||
);
|
||||
check(
|
||||
'no later frame moves the camera again',
|
||||
poseDelta(stopped, later) < 5,
|
||||
`${poseDelta(stopped, later).toFixed(2)} m drift`,
|
||||
);
|
||||
check(
|
||||
'CLEAR removes the route and both markers',
|
||||
afterClear.routeEntities === 0 && afterClear.markerCount === 0,
|
||||
);
|
||||
check(
|
||||
'CLEAR leaves the pointer free',
|
||||
afterClear.pointerOwner === null,
|
||||
`owner=${afterClear.pointerOwner}`,
|
||||
);
|
||||
|
||||
// ── 8. A tool holding the pointer blocks placement, and says so. ─────────
|
||||
await page.evaluate(() => {
|
||||
// A claim answers with a lease; the release takes that lease back.
|
||||
window.__gevQaDrawLease = window.__gevQa.claimPointer('draw');
|
||||
});
|
||||
await clickChip('set-a');
|
||||
const blocked = await layerState();
|
||||
check(
|
||||
"another tool's pointer claim is never stolen",
|
||||
blocked.pointerOwner === 'draw',
|
||||
`owner=${blocked.pointerOwner}`,
|
||||
);
|
||||
check(
|
||||
'nothing is armed, and the row says why the click would do nothing',
|
||||
blocked.chips.find((chip) => chip.id === 'set-a')?.active === false &&
|
||||
/Another map tool/.test(blocked.stats.error || ''),
|
||||
blocked.stats.error,
|
||||
);
|
||||
// The row is not where the operator is looking after pressing a chip, so the
|
||||
// refusal also has to reach the app's toast (owner field test).
|
||||
const toast = await page.evaluate(() => {
|
||||
const node = document.getElementById('toast');
|
||||
return {
|
||||
text: node?.textContent || '',
|
||||
visible: !!node?.classList.contains('visible'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the refusal reaches the app toast, naming the tool and how to leave it',
|
||||
/Draw is active/.test(toast.text) && /then set A/.test(toast.text),
|
||||
JSON.stringify(toast.text),
|
||||
);
|
||||
check('and that toast is actually on screen', toast.visible);
|
||||
await shot(`05-${LABEL}-pointer-blocked-toast.jpg`);
|
||||
const released = await page.evaluate(() =>
|
||||
window.__gevQa.releasePointer(window.__gevQaDrawLease),
|
||||
);
|
||||
check('and it gets the pointer back when it lets go', released);
|
||||
|
||||
// ── 9. Denver: maneuver dots sit on the ground, not at sea level. ────────
|
||||
if (!SKIP.has('denver')) {
|
||||
await lookAt(39.744, -104.999, 7000);
|
||||
await sleep(6000);
|
||||
const placed = await placeEndpoints(
|
||||
{ lat: 39.7392, lon: -104.9903 },
|
||||
{ lat: 39.7487, lon: -105.0077 },
|
||||
);
|
||||
check('both Denver endpoints are on screen', placed);
|
||||
if (placed) {
|
||||
await waitForRoute();
|
||||
// Let the shared ground-floor cells warm and the dots re-anchor.
|
||||
await sleep(9000);
|
||||
const denver = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
||||
const scene = viewer.scene;
|
||||
let collection = null;
|
||||
for (let i = 0; i < scene.primitives.length; i += 1) {
|
||||
const primitive = scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
collection = primitive;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!collection) return { dots: 0, shown: 0, heights: [] };
|
||||
const heights = [];
|
||||
for (let i = 0; i < collection.length; i += 1) {
|
||||
const point = collection.get(i);
|
||||
if (!point.show) continue;
|
||||
heights.push(Cartographic.fromCartesian(point.position).height);
|
||||
}
|
||||
return { dots: collection.length, shown: heights.length, heights };
|
||||
});
|
||||
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
||||
check(
|
||||
'every Denver dot resolved its ground cell — none left hidden',
|
||||
denver.dots > 0 && denver.shown === denver.dots,
|
||||
`${denver.shown} of ${denver.dots} shown`,
|
||||
);
|
||||
// Denver's ground is ~1.58-1.65 km ellipsoidal (~1.6 km orthometric plus
|
||||
// the local geoid separation). A dot left at a few metres would fail this
|
||||
// by a kilometre and a half.
|
||||
check(
|
||||
'every Denver dot is anchored on the ground, not at sea level',
|
||||
denver.dots > 0 &&
|
||||
denver.heights.length === denver.dots &&
|
||||
denver.heights.every((h) => h > 1400 && h < 1900),
|
||||
`heights ${denver.heights.map((h) => Math.round(h)).join(', ')}`,
|
||||
);
|
||||
await shot(`03-${LABEL}-denver-markers.jpg`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
||||
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
|
||||
const entities = [...window.__godsEyeView.viewer.entities.values];
|
||||
return {
|
||||
beforeDisable: entities.length,
|
||||
directionsBeforeDisable: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
).length,
|
||||
};
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await sleep(1500);
|
||||
const off = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const left = [...viewer.entities.values].filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
);
|
||||
let shownDots = 0;
|
||||
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
|
||||
const primitive = viewer.scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
shownDots = primitive.show ? primitive.length : 0;
|
||||
}
|
||||
}
|
||||
const row = document.querySelector(
|
||||
'#data-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
shownDots,
|
||||
controlsHidden:
|
||||
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
||||
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
motion: window.__gevQa.getActiveCameraMotion(),
|
||||
totalEntities: viewer.entities.values.length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'disabling removes every Directions entity',
|
||||
off.entities === 0,
|
||||
`${off.entities} left`,
|
||||
);
|
||||
check('disabling hides the maneuver dots', off.shownDots === 0);
|
||||
check(
|
||||
'disabling hides the chips and the turn list',
|
||||
off.controlsHidden && off.listHidden,
|
||||
`controls=${off.controlsHidden} list=${off.listHidden}`,
|
||||
);
|
||||
check(
|
||||
'disabling leaves the pointer free',
|
||||
off.pointerOwner === null,
|
||||
`owner=${off.pointerOwner}`,
|
||||
);
|
||||
check('disabling leaves no camera motion running', off.motion === null);
|
||||
check(
|
||||
'exactly the Directions entities went away, and nothing else did',
|
||||
off.totalEntities === beforeDisable - directionsBeforeDisable,
|
||||
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
|
||||
);
|
||||
|
||||
check(
|
||||
'no uncaught page errors',
|
||||
pageErrors.length === 0,
|
||||
pageErrors.join(' | '),
|
||||
);
|
||||
const realConsoleErrors = consoleErrors.filter(
|
||||
(text) =>
|
||||
!/Failed to load resource|net::ERR|favicon|429|ERR_ABORTED|status of 4/i.test(
|
||||
text,
|
||||
),
|
||||
);
|
||||
check(
|
||||
'no unexpected console errors',
|
||||
realConsoleErrors.length === 0,
|
||||
realConsoleErrors.slice(0, 3).join(' | '),
|
||||
);
|
||||
} catch (error) {
|
||||
check('harness completed', false, error?.stack || String(error));
|
||||
await shot(`99-${LABEL}-failure.jpg`).catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fs.writeFile(
|
||||
path.join(SHOTS, `qa-directions-${LABEL}.log`),
|
||||
`${lines.join('\n')}\n${failures} failure(s)\n`,
|
||||
);
|
||||
console.log(`\n${failures} failure(s)`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/** Verify imported camera directions through real rendering and camera authority handoffs. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = path.resolve('qa-shots/director-camera');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failed = 0;
|
||||
function check(name, passed) {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failed++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
||||
const file = {
|
||||
version: 4,
|
||||
scenes: [
|
||||
{
|
||||
id: 'directions',
|
||||
title: 'Camera directions',
|
||||
anchors: [
|
||||
{
|
||||
id: 'start',
|
||||
title: 'Austin west',
|
||||
lat: 30.2672,
|
||||
lon: -97.76,
|
||||
alt: 6000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
{
|
||||
id: 'end',
|
||||
title: 'Austin east',
|
||||
lat: 30.2672,
|
||||
lon: -97.73,
|
||||
alt: 7000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'move',
|
||||
title: 'Across Austin',
|
||||
durationSec: 1,
|
||||
holdSec: 0.2,
|
||||
camera: { anchorId: 'end', heading: 10, pitch: -45, roll: 0 },
|
||||
move: {
|
||||
from: { anchorId: 'start', heading: 350, pitch: -45, roll: 0 },
|
||||
easing: 'linear',
|
||||
},
|
||||
layers: {},
|
||||
visual: { style: 'normal' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(file)], 'directions.json'),
|
||||
);
|
||||
const scene = d._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
const imported =
|
||||
scene.anchors.length === 2 && shot.camera.anchorId === 'end';
|
||||
await d.seekScene(scene.id, 0.5 / 1.2);
|
||||
const middle = style.getCameraState();
|
||||
const seekMidpoint =
|
||||
Math.abs(middle.lon - -97.745) < 1e-6 &&
|
||||
Math.abs(middle.alt - 6500) < 0.01;
|
||||
await d.seekScene(scene.id, 0);
|
||||
const back = style.getCameraState();
|
||||
const backward = Math.abs(back.lon - -97.76) < 1e-6;
|
||||
const replay = await d.replayShot(scene.id, shot.id);
|
||||
const end = style.getCameraState();
|
||||
const replayEnd =
|
||||
replay?.started &&
|
||||
Math.abs(end.lon - -97.73) < 1e-6 &&
|
||||
Math.abs(end.alt - 7000) < 0.01;
|
||||
d.stopScene();
|
||||
await d.startScene(scene.id, { single: true, preview: false });
|
||||
const completed =
|
||||
!d.running && d.getPlaybackTimingState().activeTimers === 0;
|
||||
const settled = Math.abs(style.getCameraState().lon - -97.73) < 1e-6;
|
||||
// Updating the target creates an explicit inline pose while retaining the authored start.
|
||||
d.updateSelectedShot();
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
const updated =
|
||||
saved.scenes[0].shots[0].camera.altitudeReference === 'ellipsoid' &&
|
||||
saved.scenes[0].shots[0].move.from.anchorId === 'start';
|
||||
shot.durationSec = 30;
|
||||
const run = d.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!d._cameraMotion.active && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const started = d._cameraMotion.active;
|
||||
style.runImmediateNavigation('location', () => true);
|
||||
await run;
|
||||
const revoked =
|
||||
started &&
|
||||
!d.running &&
|
||||
!d._cameraMotion.active &&
|
||||
d.getPlaybackTimingState().activeTimers === 0;
|
||||
const stoppedPose = style.getCameraState();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const stable =
|
||||
Math.abs(style.getCameraState().lon - stoppedPose.lon) < 1e-7;
|
||||
return {
|
||||
imported,
|
||||
seekMidpoint,
|
||||
backward,
|
||||
replayEnd,
|
||||
completed,
|
||||
settled,
|
||||
updated,
|
||||
revoked,
|
||||
stable,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
await new Promise((r) => setTimeout(r, 6000));
|
||||
await page.screenshot({ path: path.join(output, 'austin.png') });
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const pose = d.styleManager.getCameraState();
|
||||
d._setCameraView({ ...pose, heading: pose.heading + 25, pitch: -60 });
|
||||
d.viewer.scene.requestRender();
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
await page.screenshot({ path: path.join(output, 'angle.png') });
|
||||
const inputStarted = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes[0];
|
||||
window.__qaCameraRun = d.startScene(scene.id, {
|
||||
single: true,
|
||||
preview: false,
|
||||
});
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!d._cameraMotion.active && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return d._cameraMotion.active;
|
||||
});
|
||||
const canvas =
|
||||
(await page.$('canvas.cesium-widget-canvas')) ||
|
||||
(await page.$('.cesium-widget canvas'));
|
||||
const rect = await canvas.boundingBox();
|
||||
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
const input =
|
||||
inputStarted &&
|
||||
(await page.evaluate(async () => {
|
||||
await window.__qaCameraRun;
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
return !d.running && d.getPlaybackTimingState().activeTimers === 0;
|
||||
}));
|
||||
check('manual canvas input revokes the move and releases clocks', input);
|
||||
const hold = await page.evaluate(async () => {
|
||||
const { sceneDirector: d, styleManager: style } = window.__godsEyeView;
|
||||
const scene = d._project.scenes[0];
|
||||
const shot = scene.shots[0];
|
||||
shot.durationSec = 0.2;
|
||||
shot.holdSec = 30;
|
||||
const run = d.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (
|
||||
(!d._activeSceneTravel || !d.running || d._cameraMotion.active) &&
|
||||
Date.now() < deadline
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const holding = d.running && !d._cameraMotion.active;
|
||||
const began = performance.now();
|
||||
style.runImmediateNavigation('location', () => true);
|
||||
await run;
|
||||
return (
|
||||
holding &&
|
||||
performance.now() - began < 1000 &&
|
||||
d.getPlaybackTimingState().activeTimers === 0
|
||||
);
|
||||
});
|
||||
check('navigation during an authored hold settles immediately', hold);
|
||||
|
||||
if (errors.length)
|
||||
console.error(
|
||||
errors.map((message) => message.replace(/https?:\/\/\S+/g, '[URL]')),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
/** Real pointer/keyboard acceptance using a synthetic, self-authored feature pack. */
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = 'qa-shots/director-interactions';
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const errors = [];
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/scene-assets/qa/actions.json')
|
||||
void request
|
||||
.respond({
|
||||
status: 200,
|
||||
contentType: 'application/geo+json',
|
||||
body: JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 600] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.catch(() => {});
|
||||
else void request.continue().catch(() => {});
|
||||
});
|
||||
const action = (id) => `[data-director-action="${id}"]`;
|
||||
const load = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.loadShot('interactive', 'one', {
|
||||
flyDuration: 0.2,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
const target = { packId: 'data', featureId: 'point' };
|
||||
const interaction = (id, label, action) => ({ id, label, target, action });
|
||||
window.__interactionProject = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'interactive',
|
||||
title: 'Interactive fixture',
|
||||
anchors: [
|
||||
{
|
||||
id: 'view',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
alt: 5000,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
{
|
||||
id: 'data',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'qa/actions.json' },
|
||||
attribution: { text: 'Synthetic fixture', license: 'CC0-1.0' },
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'one',
|
||||
title: 'Choose an action',
|
||||
camera,
|
||||
durationSec: 0.2,
|
||||
holdSec: 0,
|
||||
move: {
|
||||
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||
easing: 'linear',
|
||||
},
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
interaction('card', 'Read source', {
|
||||
type: 'card',
|
||||
text: '<b>Plain text only</b>',
|
||||
url: 'https://example.org/source',
|
||||
}),
|
||||
interaction('focus', 'Focus anchor', {
|
||||
type: 'focus',
|
||||
anchorId: 'view',
|
||||
}),
|
||||
interaction('layer', 'Show traffic', {
|
||||
type: 'layer',
|
||||
layerId: 'traffic',
|
||||
enabled: true,
|
||||
}),
|
||||
interaction('next', 'Next shot', {
|
||||
type: 'shot',
|
||||
shotId: 'two',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two',
|
||||
title: 'Next',
|
||||
camera: { ...camera, heading: 30 },
|
||||
durationSec: 0.2,
|
||||
holdSec: 0,
|
||||
layers: { traffic: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(window.__interactionProject)], 'actions.json'),
|
||||
);
|
||||
});
|
||||
check(
|
||||
'import installs no action handlers or UI',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await load();
|
||||
check(
|
||||
'settled LOAD activates four actions',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 4,
|
||||
),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const point = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const entity = d.viewer.entities.values.find(
|
||||
(e) => e.name === 'data / point',
|
||||
);
|
||||
const pos = entity.position.getValue(d.viewer.clock.currentTime);
|
||||
const screen = d.viewer.scene.cartesianToCanvasCoordinates(pos);
|
||||
const rect = d.viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
check(
|
||||
'real globe pick selects the feature and focuses its keyboard action',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.dataset.directorAction === 'card' &&
|
||||
document
|
||||
.querySelector('[data-director-interactions] [role=status]')
|
||||
.textContent.includes('point'),
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'Enter shows literal text and a safe source link',
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('[data-director-action-card]');
|
||||
return (
|
||||
card?.textContent.includes('<b>Plain text only</b>') &&
|
||||
!card.querySelector('b') &&
|
||||
card.querySelector('a')?.rel === 'noopener noreferrer'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: `${output}/selected.png` });
|
||||
fs.writeFileSync(
|
||||
`${output}/report.json`,
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Space');
|
||||
check(
|
||||
'Tab/Space focuses the authored anchor through camera ownership',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||
.height - 5000,
|
||||
) < 1,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: `${output}/anchor.png` });
|
||||
const sourceBase = process.env.QA_SOURCE_BASE || '/src';
|
||||
// Resolve ownership from the same module graph as the active application.
|
||||
await page.evaluate(async (base) => {
|
||||
window.__inputOwner = await import(`${base}/data/inputOwnership.js`);
|
||||
window.__lease = window.__inputOwner.claimPointer('qa-draw');
|
||||
}, sourceBase);
|
||||
await page.click(action('card'));
|
||||
check(
|
||||
'drawing ownership refuses actions',
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelector('[data-director-interactions] [role=status]')
|
||||
.textContent.includes('unavailable'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => window.__inputOwner.releasePointer(window.__lease));
|
||||
// Hold the actual manager admission call to exercise Stop before an enable settles.
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector,
|
||||
m = d.dataManager;
|
||||
window.__originalEnable = m.setEnabled;
|
||||
m.setEnabled = function (id, enabled, options) {
|
||||
if (id !== 'traffic' || !enabled)
|
||||
return window.__originalEnable.call(this, id, enabled, options);
|
||||
window.__actionSignal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
window.__lateAction = () => resolve(true);
|
||||
options.signal.addEventListener('abort', () => resolve(false), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
await page.click(action('layer'));
|
||||
await page.waitForFunction(() => window.__actionSignal);
|
||||
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||
check(
|
||||
'Stop aborts pending layer admission and removes handlers/cards/selection',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__actionSignal.aborted &&
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__lateAction();
|
||||
window.__godsEyeView.sceneDirector.dataManager.setEnabled =
|
||||
window.__originalEnable;
|
||||
});
|
||||
await load();
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.seekScene('interactive', 0.1),
|
||||
);
|
||||
check(
|
||||
'same-shot seek restores pack geometry and resets action state',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 4 &&
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||
null &&
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__oldActionButton = document.querySelector(
|
||||
'[data-director-action=card]',
|
||||
);
|
||||
});
|
||||
await page.click(action('next'));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.sceneDirector._selectedShotId === 'two',
|
||||
);
|
||||
check(
|
||||
'explicit shot transition replaces resources and removes actions',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 0,
|
||||
),
|
||||
);
|
||||
await load();
|
||||
await page.evaluate(() => window.__oldActionButton.click());
|
||||
check(
|
||||
'detached buttons cannot invoke replacement actions with the same ID',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().selected ===
|
||||
null &&
|
||||
!document.querySelector('[data-director-action-card]').textContent,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.sceneDirector._interactionTransitions = 64;
|
||||
});
|
||||
await page.click(action('next'));
|
||||
check(
|
||||
'bounded transition chain cannot take another branch',
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.sceneDirector._selectedShotId === 'one',
|
||||
),
|
||||
);
|
||||
await load();
|
||||
await page.evaluate(async () => {
|
||||
const p = structuredClone(window.__interactionProject);
|
||||
p.scenes[0].shots[0].interactions[0].target.featureId = 'missing';
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.importProjectFile(new File([JSON.stringify(p)], 'missing.json'));
|
||||
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||
});
|
||||
check(
|
||||
'unknown loaded feature refuses the whole action set',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!window.__godsEyeView.sceneDirector.getInteractionState().active &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(window.__interactionProject)], 'restore.json'),
|
||||
);
|
||||
await d.loadShot('interactive', 'one', { flyDuration: 0.2 });
|
||||
await d.destroy();
|
||||
});
|
||||
check(
|
||||
'teardown releases interactive resources',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 0 &&
|
||||
!document.querySelector('[data-director-interactions]'),
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env node
|
||||
/** Synthetic, self-authored assets exercise import, rendering and pack resource lifetime. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
const output = path.resolve('qa-shots/director-packs');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 80, b: 30, alpha: 0.65 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const wav = Buffer.alloc(44 + 16000);
|
||||
wav.write('RIFF');
|
||||
wav.writeUInt32LE(wav.length - 8, 4);
|
||||
wav.write('WAVEfmt ', 8);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(8000, 24);
|
||||
wav.writeUInt32LE(16000, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(16000, 40);
|
||||
const geojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'line',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.745, 30.268, 400],
|
||||
[-97.735, 30.268, 400],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'polygon',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.746, 30.265, 300],
|
||||
[-97.74, 30.265, 300],
|
||||
[-97.74, 30.267, 300],
|
||||
[-97.746, 30.265, 300],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [],
|
||||
requests = [];
|
||||
let pending,
|
||||
failures = 0;
|
||||
const check = (name, passed) => {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!url.pathname.startsWith('/scene-assets/'))
|
||||
return void request.continue();
|
||||
requests.push(url.pathname);
|
||||
if (url.pathname.endsWith('/slow.json')) {
|
||||
pending = request;
|
||||
return;
|
||||
}
|
||||
const reply = url.pathname.endsWith('/outline.json')
|
||||
? { contentType: 'application/geo+json', body: JSON.stringify(geojson) }
|
||||
: url.pathname.endsWith('/image.png')
|
||||
? { contentType: 'image/png', body: png }
|
||||
: url.pathname.endsWith('/audio.wav')
|
||||
? { contentType: 'audio/wav', body: wav }
|
||||
: { status: 404, body: 'missing' };
|
||||
void request.respond(reply).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const manifest = (id, format, file, placement) => ({
|
||||
id,
|
||||
version: 1,
|
||||
format,
|
||||
source: { adapter: 'assets', path: `qa/${file}` },
|
||||
attribution: { text: 'Synthetic QA fixture', license: 'CC0-1.0' },
|
||||
placement,
|
||||
});
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
};
|
||||
const project = {
|
||||
version: 5,
|
||||
scenes: [
|
||||
{
|
||||
id: 'packs',
|
||||
title: 'Data packs QA',
|
||||
anchors: [
|
||||
{
|
||||
id: 'media',
|
||||
lat: 30.269,
|
||||
lon: -97.737,
|
||||
alt: 450,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
manifest('outline', 'geojson', 'outline.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('image', 'image', 'image.png', {
|
||||
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||
height: 300,
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('media', 'media', 'audio.wav', { anchorId: 'media' }),
|
||||
manifest('slow', 'geojson', 'slow.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
manifest('missing', 'geojson', 'missing.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
],
|
||||
shots: ['all', 'empty', 'slow', 'missing'].map((id) => ({
|
||||
id,
|
||||
title: id,
|
||||
camera,
|
||||
durationSec: 0.3,
|
||||
holdSec: 0.2,
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: {},
|
||||
dataPackIds:
|
||||
id === 'all'
|
||||
? ['outline', 'image', 'media']
|
||||
: id === 'empty'
|
||||
? []
|
||||
: [id],
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify(project)], 'packs.json'),
|
||||
);
|
||||
window.__packBaseline = d.viewer.entities.values.length;
|
||||
window.__packPrimitiveBaseline = d.viewer.scene.primitives.length;
|
||||
window.__packRevoked = [];
|
||||
const revoke = URL.revokeObjectURL.bind(URL);
|
||||
URL.revokeObjectURL = (url) => {
|
||||
window.__packRevoked.push(url);
|
||||
revoke(url);
|
||||
};
|
||||
});
|
||||
check('import does not acquire unselected assets', requests.length === 0);
|
||||
const loaded = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const result = await d.loadShot('packs', 'all', { flyDuration: 0.3 });
|
||||
window.__packMaterials = Array.from(
|
||||
{
|
||||
length:
|
||||
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
|
||||
},
|
||||
(_, i) =>
|
||||
d.viewer.scene.primitives.get(window.__packPrimitiveBaseline + i)
|
||||
.appearance?.material,
|
||||
).filter(Boolean);
|
||||
return {
|
||||
started: result?.started,
|
||||
state: d.getDataPackState(),
|
||||
count: d.viewer.entities.values.length - window.__packBaseline,
|
||||
primitives:
|
||||
d.viewer.scene.primitives.length - window.__packPrimitiveBaseline,
|
||||
cards: document.querySelectorAll('[data-director-pack]').length,
|
||||
paused: document.querySelector('[data-director-pack] audio')?.paused,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'GeoJSON, PNG and media load only through registered sources',
|
||||
loaded.started && loaded.state.count === 3 && requests.length === 3,
|
||||
);
|
||||
check(
|
||||
'geometry and image batches plus anchored markers render separately',
|
||||
loaded.count === 2 && loaded.primitives === 3,
|
||||
);
|
||||
check(
|
||||
'attribution is visible and media does not autoplay',
|
||||
loaded.cards === 3 && loaded.paused === true,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.mapStackController.getActiveId() === 'photoreal',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const evidence = await page.evaluate(() => ({
|
||||
mapStack: window.__godsEyeView.mapStackController.getActiveId(),
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'report.json'),
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
check(
|
||||
'static geometry is ready before visual acceptance',
|
||||
await page.evaluate(() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
return Array.from(
|
||||
{ length: primitives.length - window.__packPrimitiveBaseline },
|
||||
(_, i) => primitives.get(window.__packPrimitiveBaseline + i),
|
||||
).every((p) => p.ready !== false);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'packs.png') });
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
d._setCameraView({
|
||||
...d.styleManager.getCameraState(),
|
||||
heading: 30,
|
||||
pitch: -85,
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await page.screenshot({ path: path.join(output, 'angle.png') });
|
||||
const life = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const media = document.querySelector('[data-director-pack] audio');
|
||||
await media.play();
|
||||
const played = !media.paused;
|
||||
await d.loadShot('packs', 'empty', { flyDuration: 0.3 });
|
||||
const cleared =
|
||||
d.getDataPackState().count === 0 &&
|
||||
!document.querySelector('[data-director-packs]') &&
|
||||
d.viewer.entities.values.length === window.__packBaseline &&
|
||||
d.viewer.scene.primitives.length === window.__packPrimitiveBaseline &&
|
||||
media.paused &&
|
||||
!media.getAttribute('src') &&
|
||||
window.__packRevoked.length === 2 &&
|
||||
window.__packMaterials.length === 1 &&
|
||||
window.__packMaterials.every((material) => material.isDestroyed());
|
||||
await d.seekScene('packs', 0.1);
|
||||
const seek = d.getDataPackState().count === 3;
|
||||
d.stopScene();
|
||||
const stopped =
|
||||
d.getDataPackState().count === 0 &&
|
||||
d.viewer.entities.values.length === window.__packBaseline;
|
||||
const missing = await d.loadShot('packs', 'missing', { flyDuration: 0.3 });
|
||||
return {
|
||||
played,
|
||||
cleared,
|
||||
seek,
|
||||
stopped,
|
||||
missing: missing?.started === false && d.getDataPackState().count === 0,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(life)) check(name, passed);
|
||||
await page.evaluate(() => {
|
||||
window.__pendingPack = window.__godsEyeView.sceneDirector.loadShot(
|
||||
'packs',
|
||||
'slow',
|
||||
);
|
||||
});
|
||||
const deadline = Date.now() + 15000;
|
||||
while (!pending && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
check('pending transport reached', !!pending);
|
||||
const cancelled = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const start = performance.now();
|
||||
d.stopScene();
|
||||
await window.__pendingPack;
|
||||
return performance.now() - start < 1000 && d.getDataPackState().count === 0;
|
||||
});
|
||||
check('Stop promptly settles a pending transport', cancelled);
|
||||
if (pending)
|
||||
await pending
|
||||
.respond({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(geojson),
|
||||
})
|
||||
.catch(() => {});
|
||||
check(
|
||||
'late response cannot reintroduce entities',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.entities.values.length ===
|
||||
window.__packBaseline &&
|
||||
window.__godsEyeView.viewer.scene.primitives.length ===
|
||||
window.__packPrimitiveBaseline,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
await d.loadShot('packs', 'all', { flyDuration: 0.3 });
|
||||
await d.importProjectFile(
|
||||
new File([JSON.stringify({ version: 5, scenes: [] })], 'empty.json'),
|
||||
);
|
||||
});
|
||||
check(
|
||||
'replacement import clears every pack resource',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 0 &&
|
||||
!document.querySelector('[data-director-packs]'),
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
if (errors.length)
|
||||
console.error(errors.map((e) => e.replace(/https?:\/\/\S+/g, '[URL]')));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,482 @@
|
||||
#!/usr/bin/env node
|
||||
/** Sharing/authoring acceptance with local synthetic assets and real installed controls. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const output = path.resolve('qa-shots/director-sharing');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const geometry = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.2672, 500] },
|
||||
},
|
||||
],
|
||||
};
|
||||
fs.writeFileSync(path.join(output, 'data.geojson'), JSON.stringify(geometry));
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
};
|
||||
const project = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'share',
|
||||
title: 'Sharing fixture',
|
||||
dataPacks: [
|
||||
{
|
||||
id: 'data',
|
||||
version: 1,
|
||||
format: 'geojson',
|
||||
source: { adapter: 'assets', path: 'example/data.geojson' },
|
||||
attribution: { text: 'Synthetic <b>fixture</b>', license: 'CC0-1.0' },
|
||||
placement: { altitudeReference: 'ellipsoid' },
|
||||
},
|
||||
],
|
||||
shots: [
|
||||
{
|
||||
id: 'shot',
|
||||
title: 'First',
|
||||
camera,
|
||||
durationSec: 0.3,
|
||||
holdSec: 0,
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
layers: {},
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
{
|
||||
id: 'info',
|
||||
label: 'Read fixture',
|
||||
target: { packId: 'data', featureId: 'point' },
|
||||
action: { type: 'card', text: 'Synthetic scene note' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'second', title: 'Other scene', shots: [] },
|
||||
],
|
||||
};
|
||||
const file = path.join(output, 'project.json');
|
||||
fs.writeFileSync(file, JSON.stringify(project));
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
let failures = 0;
|
||||
const errors = [],
|
||||
requests = [];
|
||||
const check = (name, ok) => {
|
||||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('request', (r) => {
|
||||
if (new URL(r.url()).pathname.startsWith('/scene-assets/'))
|
||||
requests.push(r.url());
|
||||
});
|
||||
async function click(label) {
|
||||
const handle = await page.evaluateHandle(
|
||||
(label) =>
|
||||
[...document.querySelectorAll('button')].find(
|
||||
(b) => b.textContent === label,
|
||||
),
|
||||
label,
|
||||
);
|
||||
await handle.asElement().click();
|
||||
await handle.dispose();
|
||||
}
|
||||
async function value(label, text) {
|
||||
await page.$eval(
|
||||
`[aria-label="${label}"]`,
|
||||
(node, text) => {
|
||||
node.value = text;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.click('[data-collapse-target="scene-panel"]');
|
||||
const before = await page.evaluate(() =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||
);
|
||||
const input = await page.$('#scene-import-file');
|
||||
await input.uploadFile(file);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'preview parses metadata without acquiring assets or replacing the current project',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
(before) =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) ===
|
||||
before,
|
||||
before,
|
||||
)),
|
||||
);
|
||||
check(
|
||||
'preview renders author attribution as literal text',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog]')
|
||||
.textContent.includes('Synthetic <b>fixture</b>') &&
|
||||
!document.querySelector('[data-director-dialog] b'),
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
check(
|
||||
'Escape cancels preview without altering saved work',
|
||||
await page.evaluate(
|
||||
(before) =>
|
||||
!document.querySelector('[data-director-dialog]') &&
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) === before,
|
||||
before,
|
||||
),
|
||||
);
|
||||
await input.uploadFile(file);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'Apply import replaces metadata but does not start playback or fetch a pack',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes.length === 2 &&
|
||||
!window.__godsEyeView.sceneDirector.running,
|
||||
)),
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await click('Capture camera as anchor');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Anchor added'),
|
||||
);
|
||||
await click('Set move start to current camera');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Move added'),
|
||||
);
|
||||
const draft = await page.$eval(
|
||||
'[aria-label="Shot camera, timing, packs and actions"]',
|
||||
(n) => n.value,
|
||||
);
|
||||
const bad = JSON.parse(draft);
|
||||
bad.camera.lat = 91;
|
||||
await value('Shot camera, timing, packs and actions', JSON.stringify(bad));
|
||||
const saved = await page.evaluate(() =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('camera.lat'),
|
||||
);
|
||||
check(
|
||||
'invalid draft preserves saved bytes and existing authored content',
|
||||
await page.evaluate(
|
||||
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
|
||||
saved,
|
||||
),
|
||||
);
|
||||
await value('Shot camera, timing, packs and actions', draft);
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'author controls preserve selected shot, pack attribution and action definitions',
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector,
|
||||
s = d._getSelectedScene();
|
||||
return (
|
||||
d._selectedShotId === 'shot' &&
|
||||
s.anchors.length === 1 &&
|
||||
s.shots[0].move.easing === 'cubic-in-out' &&
|
||||
s.shots[0].interactions.length === 1 &&
|
||||
s.dataPacks[0].attribution.license === 'CC0-1.0'
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__shareExports = [];
|
||||
const create = URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL = (blob) => {
|
||||
if (blob.type === 'application/json')
|
||||
window.__shareExports.push(blob.text());
|
||||
return create(blob);
|
||||
};
|
||||
});
|
||||
await click('SHARE SCENE');
|
||||
await click('Download scene JSON');
|
||||
const exported = await page.evaluate(async () =>
|
||||
JSON.parse(await window.__shareExports.at(-1)),
|
||||
);
|
||||
check(
|
||||
'scene JSON exports only the selected scene and keeps its credit',
|
||||
exported.scenes.length === 1 &&
|
||||
exported.scenes[0].dataPacks[0].attribution.license === 'CC0-1.0',
|
||||
);
|
||||
await click('Download asset bundle');
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent.includes('Could not complete'),
|
||||
);
|
||||
check(
|
||||
'missing explicit files produce no partial download or network fallback',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(() => window.__shareExports.length === 1)),
|
||||
);
|
||||
const files = await page.$('[aria-label="Choose data-pack files"]');
|
||||
await files.uploadFile(path.join(output, 'data.geojson'));
|
||||
await click('Download asset bundle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('[data-director-dialog] [role=status]')
|
||||
.textContent === 'Asset bundle downloaded',
|
||||
);
|
||||
const bundle = await page.evaluate(
|
||||
async () => await window.__shareExports.at(-1),
|
||||
);
|
||||
const bundleFile = path.join(output, 'scene.gevbundle.json');
|
||||
fs.writeFileSync(bundleFile, bundle);
|
||||
check(
|
||||
'bundle contains the selected bytes and preserves attribution',
|
||||
JSON.parse(bundle).assets.length === 1 &&
|
||||
JSON.parse(bundle).project.scenes[0].dataPacks[0].attribution.license ===
|
||||
'CC0-1.0',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await input.uploadFile(bundleFile);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'bundle preview verifies bytes and explains session-only storage',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog]')
|
||||
.textContent.includes('bundled bytes verified') &&
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'preview.png') });
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.loadShot('share', 'shot', {
|
||||
flyDuration: 0.3,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'imported bundle renders and enables feature actions with zero asset requests',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getDataPackState().count === 1 &&
|
||||
window.__godsEyeView.sceneDirector.getInteractionState().count === 1,
|
||||
)),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'rendered.png') });
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'report.json'),
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
})),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.sceneDirector.stopScene());
|
||||
check(
|
||||
'Stop releases rendered resources while retaining reusable project bytes',
|
||||
await page.evaluate(() => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
return (
|
||||
d.getDataPackState().count === 0 &&
|
||||
d.getSharingState().assets.count === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.seekScene('share', 0),
|
||||
);
|
||||
check(
|
||||
'seek reloads the bundle without a network fallback',
|
||||
requests.length === 0 &&
|
||||
(await page.evaluate(
|
||||
() => window.__godsEyeView.sceneDirector.getDataPackState().count === 1,
|
||||
)),
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.sceneDirector._project.scenes[0].title = 'Newer edit';
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
check(
|
||||
'stale author draft cannot overwrite a newer edit',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector._project.scenes[0].title ===
|
||||
'Newer edit',
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await click('SHARE SCENE');
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
check(
|
||||
'modal remains within a narrow viewport',
|
||||
await page.$eval('[data-director-dialog]', (n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return (
|
||||
r.left >= 0 &&
|
||||
r.right <= innerWidth &&
|
||||
r.top >= 0 &&
|
||||
r.bottom <= innerHeight
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.screenshot({ path: path.join(output, 'narrow.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
const pending = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
let finish;
|
||||
const work = d._sharing.preview({
|
||||
name: 'pending.json',
|
||||
text: () => new Promise((r) => (finish = r)),
|
||||
});
|
||||
d._sharing.close();
|
||||
await work;
|
||||
finish(JSON.stringify({ version: 6, scenes: [] }));
|
||||
return !d.getSharingState().open;
|
||||
});
|
||||
check(
|
||||
'cancel settles a pending file read and suppresses its late completion',
|
||||
pending,
|
||||
);
|
||||
const cancelledApply = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const before = JSON.stringify(d._project);
|
||||
const saved = JSON.stringify({ ...localStorage });
|
||||
let finish;
|
||||
const pendingWork = new Promise((resolve) => (finish = resolve));
|
||||
d._pendingWork.add(pendingWork);
|
||||
const owner = new AbortController();
|
||||
const work = d.importProjectFile(
|
||||
{ name: 'cancelled.json' },
|
||||
{
|
||||
prepared: { project: { version: 6, scenes: [] }, assets: new Map() },
|
||||
expectedProject: JSON.stringify(d._project, null, 2),
|
||||
signal: owner.signal,
|
||||
},
|
||||
);
|
||||
owner.abort();
|
||||
finish();
|
||||
const applied = await work;
|
||||
d._pendingWork.delete(pendingWork);
|
||||
return (
|
||||
applied === false &&
|
||||
JSON.stringify(d._project) === before &&
|
||||
JSON.stringify({ ...localStorage }) === saved &&
|
||||
d.getSharingState().assets.count === 1
|
||||
);
|
||||
});
|
||||
check(
|
||||
'cancelling Apply during pending scene cleanup preserves saved project and bundle bytes',
|
||||
cancelledApply,
|
||||
);
|
||||
await click('EDIT DETAILS');
|
||||
await page.evaluate(() => {
|
||||
const fields = [
|
||||
...document.querySelectorAll('[data-director-dialog] textarea'),
|
||||
];
|
||||
const scene = JSON.parse(fields[0].value),
|
||||
shot = JSON.parse(fields[1].value);
|
||||
scene.dataPacks = [];
|
||||
shot.dataPackIds = [];
|
||||
shot.interactions = [];
|
||||
fields[0].value = JSON.stringify(scene);
|
||||
fields[1].value = JSON.stringify(shot);
|
||||
});
|
||||
await page.click('[data-director-apply-details]');
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-director-dialog]'),
|
||||
);
|
||||
check(
|
||||
'removing a pack through authoring releases its retained bytes',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.evaluate(
|
||||
(text) =>
|
||||
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||
new File([text], 'again.gevbundle.json'),
|
||||
),
|
||||
bundle,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.sceneDirector.importProjectFile(
|
||||
new File([JSON.stringify({ version: 6, scenes: [] })], 'empty.json'),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'replacement releases stored bundle bytes',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getSharingState().assets.count === 0,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
void d._sharing.preview({
|
||||
name: 'pending.json',
|
||||
text: () => new Promise(() => {}),
|
||||
});
|
||||
await d.destroy();
|
||||
});
|
||||
check(
|
||||
'teardown removes pending dialogs and authoring controls',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
!document.querySelector('[data-director-dialog]') &&
|
||||
!document.querySelector('[data-director-authoring]') &&
|
||||
!window.__godsEyeView.sceneDirector.getSharingState().open,
|
||||
),
|
||||
);
|
||||
check('no uncaught browser errors', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/** Exercise scene-clock ownership and authored seek behavior in the composed browser app. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
const shots = path.resolve('qa-shots/director-timing');
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
let failures = 0;
|
||||
function check(name, passed) {
|
||||
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
|
||||
if (!passed) failures++;
|
||||
}
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async () => {
|
||||
const director = window.__godsEyeView.sceneDirector;
|
||||
const camera = director.styleManager.getCameraState();
|
||||
const visual = director.styleManager.getVisualState();
|
||||
const scene = {
|
||||
id: 'timing-qa',
|
||||
title: 'Timing QA',
|
||||
shots: [0, 1].map((index) => ({
|
||||
id: `timing-${index}`,
|
||||
title: `Timing ${index}`,
|
||||
durationSec: 0.4,
|
||||
holdSec: 0.3,
|
||||
camera: { ...camera, lon: camera.lon + index * 0.002 },
|
||||
visual,
|
||||
layers: {},
|
||||
})),
|
||||
};
|
||||
director._project.scenes.push(scene);
|
||||
const load = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||
flyDuration: 0.3,
|
||||
});
|
||||
const forward = await director.seekScene(scene.id, 0.9);
|
||||
const forwardSnapshot = director.getPlaybackTimingState().snapshot;
|
||||
const backward = await director.seekScene(scene.id, 0.1);
|
||||
const backwardSnapshot = director.getPlaybackTimingState().snapshot;
|
||||
const replay = await director.replayShot(scene.id, scene.shots[1].id);
|
||||
director.stopScene();
|
||||
await director.startScene(scene.id, { single: true, preview: false });
|
||||
const completed =
|
||||
!director.running && director.getPlaybackTimingState().activeTimers === 0;
|
||||
scene.shots[0].holdSec = 60;
|
||||
const run = director.startScene(scene.id, { single: true, preview: false });
|
||||
const deadline = Date.now() + 10000;
|
||||
while (
|
||||
director.getPlaybackTimingState().activeTimers < 3 &&
|
||||
Date.now() < deadline
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
const holding = director.getPlaybackTimingState().activeTimers >= 3;
|
||||
const stoppedAt = performance.now();
|
||||
director.stopScene('QA Stop during hold');
|
||||
await run;
|
||||
const stoppedPromptly = performance.now() - stoppedAt < 1000;
|
||||
const noTimers = director.getPlaybackTimingState().activeTimers === 0;
|
||||
director._project.scenes = director._project.scenes.filter(
|
||||
({ id }) => id !== scene.id,
|
||||
);
|
||||
return {
|
||||
loaded: load?.started,
|
||||
forward: forward && forwardSnapshot.shotId === 'timing-1',
|
||||
backward: backward && backwardSnapshot.shotId === 'timing-0',
|
||||
replay: replay?.started,
|
||||
completed,
|
||||
holding,
|
||||
stoppedPromptly,
|
||||
noTimers,
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
const content = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find(({ title }) =>
|
||||
/Nepal Flood Incident/i.test(title),
|
||||
);
|
||||
if (!scene) return { exists: false };
|
||||
const shot =
|
||||
scene.shots.find(({ title }) => /Regional Approach/i.test(title)) ||
|
||||
scene.shots[0];
|
||||
const loaded = await d.loadShot(scene.id, shot.id, { flyDuration: 0.3 });
|
||||
return {
|
||||
exists: true,
|
||||
shots: scene.shots.length,
|
||||
loaded: loaded?.started === true,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'existing Nepal scene and authored shots load',
|
||||
content.exists && content.shots >= 25 && content.loaded,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
await page.screenshot({ path: path.join(shots, 'nepal-regional.png') });
|
||||
await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
g.sceneDirector.stopScene();
|
||||
const pose = g.styleManager.getCameraState();
|
||||
g.sceneDirector._setCameraView({
|
||||
...pose,
|
||||
heading: pose.heading + 45,
|
||||
pitch: -55,
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await page.screenshot({ path: path.join(shots, 'nepal-angle.png') });
|
||||
check(
|
||||
'no pending clocks after Stop',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.sceneDirector.getPlaybackTimingState()
|
||||
.activeTimers === 0,
|
||||
),
|
||||
);
|
||||
check('no browser exceptions', errors.length === 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures) process.exitCode = 1;
|
||||
@@ -0,0 +1,876 @@
|
||||
#!/usr/bin/env node
|
||||
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
|
||||
const SHOT_PREFIX = process.env.QA_SHOT_PREFIX || '';
|
||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||
// A dense, recognisable grid: neighbourhood blocks to enclose and a straight
|
||||
// road to trace along.
|
||||
const VIEW = {
|
||||
lon: -122.4194,
|
||||
lat: 37.7749,
|
||||
height: 900,
|
||||
heading: 0,
|
||||
pitch: -85,
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: !HEADFUL,
|
||||
...(HEADFUL
|
||||
? {
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
}
|
||||
: {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
...(HEADFUL ? ['--window-size=1280,860'] : []),
|
||||
...(process.platform === 'darwin'
|
||||
? ['--use-angle=metal', '--enable-gpu']
|
||||
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
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, `${SHOT_PREFIX}${name}.jpg`),
|
||||
type: 'jpeg',
|
||||
quality: 82,
|
||||
});
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** A clip of the globe canvas, clear of the panels on every side. */
|
||||
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
|
||||
|
||||
/**
|
||||
* How many amber pixels the clip contains. The drawn area's stroke and fill are
|
||||
* the only amber in this scene, so the count going UP after a shape is finished
|
||||
* is the shape actually painting — which is what CESIUM_3D_TILE-only
|
||||
* classification failed to do on a keyless boot while still reporting a mark on
|
||||
* the board. Decoding happens in the page because Node here has no image
|
||||
* decoder.
|
||||
*/
|
||||
async function amberPixels() {
|
||||
const shot64 = await page.screenshot({
|
||||
clip: CANVAS_CLIP,
|
||||
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);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
let amber = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
// Warm and saturated: the amber mark (#ffb547) blended over imagery.
|
||||
if (r > 150 && g > 90 && g < 220 && b < 130 && r - b > 70 && r - g > 20)
|
||||
amber += 1;
|
||||
}
|
||||
return amber;
|
||||
}, shot64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press Enter as the person drawing does — with the map focused, not a button.
|
||||
* Enter on a focused control means "press this control", so the tool ignores it
|
||||
* there; the harness has to move focus the way a user would before using the
|
||||
* finish key.
|
||||
*/
|
||||
async function pressFinish() {
|
||||
await page.evaluate(() => document.activeElement?.blur?.());
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
/** One click on the globe canvas, the way a person makes one. */
|
||||
async function clickWorld(x, y, options = {}) {
|
||||
await page.mouse.click(x, y, options);
|
||||
await wait(options.settle ?? 140);
|
||||
}
|
||||
|
||||
const drawState = () =>
|
||||
page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
return {
|
||||
...tool.diagnostics(),
|
||||
shape: tool.shape,
|
||||
vertices: tool.session?.vertices?.length ?? null,
|
||||
hint: document.getElementById('draw-hint')?.textContent || '',
|
||||
marks: window.__gevAnnotations.count(),
|
||||
};
|
||||
});
|
||||
|
||||
const marks = () =>
|
||||
page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((mark) => ({
|
||||
type: mark.type,
|
||||
label: mark.label,
|
||||
color: mark.color,
|
||||
source: mark.source ?? null,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Wait until the board has settled at `expected` marks (annotate is async). */
|
||||
async function waitForMarks(expected, timeout = 15_000) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
(want) => window.__gevAnnotations.count() === want,
|
||||
{ timeout },
|
||||
expected,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevDrawTool &&
|
||||
window.__gevAnnotations &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: (view.heading * Math.PI) / 180,
|
||||
pitch: (view.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, VIEW);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let index = 0; index < primitives.length; index++) {
|
||||
const primitive = primitives.get(index);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible tile content settles before drawing', settled);
|
||||
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user does.
|
||||
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('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
check(
|
||||
'the Draw control is on the DISPLAY rail and starts off',
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.getElementById('draw-toggle');
|
||||
return (
|
||||
toggle?.getAttribute('aria-pressed') === 'false' &&
|
||||
!document.getElementById('draw-mode-row')?.classList.contains('visible')
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// The viewer's OWN click actions, captured before the tool ever runs. Both
|
||||
// must be absent while drawing and back — as the SAME functions — after.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
});
|
||||
const stockActions = () =>
|
||||
page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
const live = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
return {
|
||||
clickPresent: Boolean(live.click),
|
||||
doublePresent: Boolean(live.double),
|
||||
clickIsOriginal: live.click === window.__qaStock.click,
|
||||
doubleIsOriginal: live.double === window.__qaStock.double,
|
||||
hadClick: Boolean(window.__qaStock.click),
|
||||
hadDouble: Boolean(window.__qaStock.double),
|
||||
};
|
||||
});
|
||||
|
||||
// ── enable / disable cycles leave nothing behind ────────────────────────
|
||||
const before = await drawState();
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
await page.click('#draw-toggle');
|
||||
await wait(120);
|
||||
await page.click('#draw-toggle');
|
||||
await wait(120);
|
||||
}
|
||||
const after = await drawState();
|
||||
check(
|
||||
'three enable/disable cycles add no listener, handler or preview data source',
|
||||
after.domListeners === before.domListeners &&
|
||||
after.previewDataSources === 1 &&
|
||||
before.previewDataSources === 1 &&
|
||||
after.sceneHandler === false &&
|
||||
after.pointerOwner === null &&
|
||||
after.stockDoubleClick === before.stockDoubleClick,
|
||||
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
await page.click('#draw-toggle');
|
||||
const duringDraw = await stockActions();
|
||||
check(
|
||||
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
|
||||
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
|
||||
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
|
||||
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
|
||||
);
|
||||
await page.click('#draw-toggle');
|
||||
const afterDraw = await stockActions();
|
||||
check(
|
||||
'and both are restored as the SAME functions, not replacements',
|
||||
afterDraw.clickPresent === duringDraw.hadClick &&
|
||||
afterDraw.doublePresent === duringDraw.hadDouble &&
|
||||
afterDraw.clickIsOriginal &&
|
||||
afterDraw.doubleIsOriginal,
|
||||
JSON.stringify(afterDraw),
|
||||
);
|
||||
|
||||
// ── a pin, finished with Enter ──────────────────────────────────────────
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="pin"]');
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Pin one');
|
||||
await clickWorld(520, 330);
|
||||
check(
|
||||
'the pointer is claimed while a session is open',
|
||||
(await drawState()).pointerOwner === 'draw',
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a pin placed by mouse and finished with Enter lands on the board',
|
||||
await waitForMarks(1),
|
||||
);
|
||||
|
||||
// ── a line along a road, with Backspace ─────────────────────────────────
|
||||
await page.click('.pp-mode-btn[data-shape="line"]');
|
||||
await clickWorld(360, 300);
|
||||
await clickWorld(470, 380);
|
||||
await clickWorld(600, 470);
|
||||
const beforeBackspace = (await drawState()).vertices;
|
||||
await page.keyboard.press('Backspace');
|
||||
await wait(120);
|
||||
const afterBackspace = (await drawState()).vertices;
|
||||
check(
|
||||
'Backspace removes the last vertex only',
|
||||
beforeBackspace === 3 && afterBackspace === 2,
|
||||
`${beforeBackspace} → ${afterBackspace}`,
|
||||
);
|
||||
await clickWorld(640, 500);
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Market St');
|
||||
await page.keyboard.press('Enter');
|
||||
check(
|
||||
'a line finished from the label field lands on the board',
|
||||
await waitForMarks(2),
|
||||
);
|
||||
const lineMark = (await marks()).find((mark) =>
|
||||
mark.label?.startsWith('Market St'),
|
||||
);
|
||||
check(
|
||||
'a drawn line reports its length and never a travel time',
|
||||
Boolean(lineMark) &&
|
||||
/\d/.test(lineMark.label) &&
|
||||
!/min|walk|drive/i.test(lineMark.label),
|
||||
lineMark?.label,
|
||||
);
|
||||
await shot('02-line-along-a-road');
|
||||
|
||||
// ── an area, coloured and labelled, finished with a double-click ────────
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await page.select('#draw-color-select', 'amber');
|
||||
const amberBefore = await amberPixels();
|
||||
await page.click('#draw-label-input');
|
||||
await page.keyboard.type('Civic Center block');
|
||||
await clickWorld(400, 280);
|
||||
await clickWorld(700, 290);
|
||||
await clickWorld(720, 520);
|
||||
await clickWorld(410, 500);
|
||||
const areaVertices = (await drawState()).vertices;
|
||||
check(
|
||||
'four clicks on the globe are four vertices',
|
||||
areaVertices === 4,
|
||||
`${areaVertices} vertices`,
|
||||
);
|
||||
await page.mouse.click(410, 500, { count: 2 });
|
||||
const areaLanded = await waitForMarks(3);
|
||||
await wait(900);
|
||||
const afterDoubleClick = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
check('an area finished by double-click lands on the board', areaLanded);
|
||||
check(
|
||||
'a double-click finishes the shape exactly once',
|
||||
afterDoubleClick === 3,
|
||||
`${afterDoubleClick} marks`,
|
||||
);
|
||||
const areaMark = (await marks()).find(
|
||||
(mark) => mark.label === 'Civic Center block',
|
||||
);
|
||||
check(
|
||||
'the label and colour chosen in the panel are what is drawn',
|
||||
areaMark?.color === 'amber',
|
||||
JSON.stringify(areaMark),
|
||||
);
|
||||
await wait(1200);
|
||||
const amberAfter = await amberPixels();
|
||||
check(
|
||||
'the finished area actually PAINTS — amber pixels appear on this surface',
|
||||
amberAfter - amberBefore > 500,
|
||||
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
|
||||
);
|
||||
await shot('01-area-with-label');
|
||||
await shot('03-pins-and-shapes');
|
||||
|
||||
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
|
||||
await page.evaluate(() => window.__gevDrawTool.cancel());
|
||||
await clickWorld(500, 300);
|
||||
await clickWorld(560, 340);
|
||||
check('a new shape is in progress', (await drawState()).vertices === 2);
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
const afterFirstEscape = await drawState();
|
||||
check(
|
||||
'Escape cancels the shape in progress and stays in draw mode',
|
||||
afterFirstEscape.vertices === 0 && afterFirstEscape.active === true,
|
||||
`vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
const afterSecondEscape = await drawState();
|
||||
check(
|
||||
'a second Escape leaves draw mode and gives the pointer back',
|
||||
afterSecondEscape.active === false &&
|
||||
afterSecondEscape.pointerOwner === null &&
|
||||
afterSecondEscape.sceneHandler === false,
|
||||
JSON.stringify(afterSecondEscape),
|
||||
);
|
||||
check(
|
||||
'cancelling never removes a mark already on the board',
|
||||
afterSecondEscape.marks === 3,
|
||||
`${afterSecondEscape.marks} marks`,
|
||||
);
|
||||
|
||||
// ── a click that would select something selects nothing while drawing ───
|
||||
const target = await findClickableTarget();
|
||||
check(
|
||||
'a selectable layer entity is on screen to test click ownership against',
|
||||
Boolean(target),
|
||||
target
|
||||
? `${target.layer} at ${target.x},${target.y}`
|
||||
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
|
||||
);
|
||||
if (target) {
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const selectedNormally = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking a ${target.layer} selects it when nothing owns the pointer`,
|
||||
selectedNormally,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.selectedEntity = undefined;
|
||||
});
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const whileDrawing = await drawState();
|
||||
const selectedWhileDrawing = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
check(
|
||||
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
|
||||
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
|
||||
`vertices ${whileDrawing.vertices}, selected ${selectedWhileDrawing}`,
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Escape');
|
||||
await wait(150);
|
||||
}
|
||||
|
||||
// ── what a reload actually does ─────────────────────────────────────────
|
||||
const beforeReload = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevAnnotations &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90_000 },
|
||||
);
|
||||
const afterReload = await page.evaluate(() =>
|
||||
window.__gevAnnotations.count(),
|
||||
);
|
||||
check(
|
||||
'a reload starts an empty board — marks live for the session, not across reloads',
|
||||
beforeReload > 0 && afterReload === 0,
|
||||
`${beforeReload} before, ${afterReload} after`,
|
||||
);
|
||||
|
||||
// ── Clear wipes the board ───────────────────────────────────────────────
|
||||
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('draw-toggle')?.getBoundingClientRect().width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
// The reload built a new page context: re-capture the viewer's own click
|
||||
// actions so the identity comparisons below still mean something.
|
||||
await page.evaluate(() => {
|
||||
const Cesium = window.__CESIUM__;
|
||||
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
|
||||
window.__qaStock = {
|
||||
click:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
|
||||
double:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null,
|
||||
};
|
||||
});
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(420, 280);
|
||||
await clickWorld(700, 300);
|
||||
await clickWorld(660, 520);
|
||||
await pressFinish();
|
||||
const drewAgain = await waitForMarks(1);
|
||||
check('a shape can be drawn again after a reload', drewAgain);
|
||||
|
||||
// Enter belongs to whatever the keyboard is on. With Clear focused it must
|
||||
// PRESS CLEAR — the shape in progress goes, and so does the mark already on
|
||||
// the board — and it must NOT quietly finish a second shape.
|
||||
await clickWorld(430, 300);
|
||||
await clickWorld(690, 320);
|
||||
await clickWorld(670, 500);
|
||||
const beforeButtonEnter = await drawState();
|
||||
await page.focus('#draw-clear');
|
||||
await page.keyboard.press('Enter');
|
||||
await wait(600);
|
||||
const afterButtonEnter = await drawState();
|
||||
check(
|
||||
'Enter on a keyboard-focused button activates the button, it does not finish the shape',
|
||||
beforeButtonEnter.vertices === 3 &&
|
||||
beforeButtonEnter.marks === 1 &&
|
||||
afterButtonEnter.marks === 0 &&
|
||||
afterButtonEnter.vertices === 0,
|
||||
`before ${beforeButtonEnter.vertices} vertices / ${beforeButtonEnter.marks} marks, ` +
|
||||
`after ${afterButtonEnter.vertices} / ${afterButtonEnter.marks} ` +
|
||||
'(a finish would have made it 2 marks)',
|
||||
);
|
||||
|
||||
// Put one back so the Clear BUTTON check below has something to remove.
|
||||
await clickWorld(420, 280);
|
||||
await clickWorld(700, 300);
|
||||
await clickWorld(660, 520);
|
||||
await pressFinish();
|
||||
check(
|
||||
'a shape can be drawn after the board was cleared from the keyboard',
|
||||
await waitForMarks(1),
|
||||
);
|
||||
await page.click('#draw-clear');
|
||||
await wait(400);
|
||||
const cleared = await drawState();
|
||||
check(
|
||||
'Clear removes every mark from the board',
|
||||
cleared.marks === 0,
|
||||
`${cleared.marks} marks left`,
|
||||
);
|
||||
await shot('04-after-clear');
|
||||
|
||||
// ── a shape that describes nothing is refused, not placed ───────────────
|
||||
const degenerateVertices = await page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
tool.cancel();
|
||||
// Three points on one line, placed through the same addVertex the clicks use.
|
||||
tool.addVertex(-122.42, 37.77);
|
||||
tool.addVertex(-122.42, 37.771);
|
||||
tool.addVertex(-122.42, 37.772);
|
||||
return tool.session?.vertices?.length ?? null;
|
||||
});
|
||||
check(
|
||||
'three collinear vertices are accepted into the session',
|
||||
degenerateVertices === 3,
|
||||
`${degenerateVertices} vertices`,
|
||||
);
|
||||
await pressFinish();
|
||||
await wait(600);
|
||||
const degenerate = await drawState();
|
||||
check(
|
||||
'three points in a line are refused with a reason, not placed',
|
||||
degenerate.marks === 0 && /in a line/.test(degenerate.hint),
|
||||
`${degenerate.marks} marks, hint "${degenerate.hint}"`,
|
||||
);
|
||||
|
||||
// ── a shape drawn across the antimeridian lands where it was drawn ──────
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (179.9995 * Math.PI) / 180,
|
||||
latitude: 0,
|
||||
height: 3000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await wait(2500);
|
||||
await page.evaluate(() => {
|
||||
const tool = window.__gevDrawTool;
|
||||
tool.cancel();
|
||||
tool.setShape('area');
|
||||
// A ~200 m square straddling 180.
|
||||
tool.addVertex(179.999, -0.001);
|
||||
tool.addVertex(-179.999, -0.001);
|
||||
tool.addVertex(-179.999, 0.001);
|
||||
tool.addVertex(179.999, 0.001);
|
||||
});
|
||||
await pressFinish();
|
||||
const datelineLanded = await waitForMarks(1);
|
||||
const datelineAnchor = await page.evaluate(() => {
|
||||
const mark = window.__gevAnnotations.list()[0];
|
||||
return mark ? { lon: mark.anchor?.lon, lat: mark.anchor?.lat } : null;
|
||||
});
|
||||
check(
|
||||
'a shape drawn across the antimeridian anchors there, not on the Greenwich meridian',
|
||||
datelineLanded &&
|
||||
datelineAnchor &&
|
||||
Math.abs(Math.abs(datelineAnchor.lon) - 180) < 0.1 &&
|
||||
Math.abs(datelineAnchor.lat) < 0.1,
|
||||
JSON.stringify(datelineAnchor),
|
||||
);
|
||||
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||
|
||||
await page.click('#draw-toggle');
|
||||
await wait(200);
|
||||
const finalState = await drawState();
|
||||
check(
|
||||
'leaving draw mode gives back the pointer, the handler and the double-click',
|
||||
finalState.active === false &&
|
||||
finalState.pointerOwner === null &&
|
||||
finalState.sceneHandler === false &&
|
||||
finalState.previewDataSources === 1,
|
||||
JSON.stringify(finalState),
|
||||
);
|
||||
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
|
||||
const beforeDestroy = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.dataSources.length,
|
||||
);
|
||||
await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case
|
||||
await wait(200);
|
||||
const destroyed = await page.evaluate(async () => {
|
||||
const tool = window.__gevDrawTool;
|
||||
const wasActive = tool.active;
|
||||
await tool.destroy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const Cesium = window.__CESIUM__;
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
let previews = 0;
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1)
|
||||
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
|
||||
return {
|
||||
wasActive,
|
||||
diagnostics: tool.diagnostics(),
|
||||
previews,
|
||||
dataSources: viewer.dataSources.length,
|
||||
handleGone: window.__gevDrawTool === undefined,
|
||||
clickRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
|
||||
window.__qaStock.click,
|
||||
doubleRestored:
|
||||
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
|
||||
window.__qaStock.double,
|
||||
drawingClass: document.body.classList.contains('gev-drawing'),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'destroy() from an active session releases the pointer, handler and listeners',
|
||||
destroyed.wasActive === true &&
|
||||
destroyed.diagnostics.destroyed === true &&
|
||||
destroyed.diagnostics.pointerOwner === null &&
|
||||
destroyed.diagnostics.sceneHandler === false &&
|
||||
destroyed.diagnostics.domListeners === 0 &&
|
||||
destroyed.drawingClass === false,
|
||||
JSON.stringify(destroyed.diagnostics),
|
||||
);
|
||||
check(
|
||||
'destroy() detaches the preview data source and gives the window handle back',
|
||||
destroyed.previews === 0 &&
|
||||
destroyed.dataSources === beforeDestroy - 1 &&
|
||||
destroyed.handleGone,
|
||||
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
|
||||
);
|
||||
check(
|
||||
"destroy() returns Cesium's own click actions",
|
||||
destroyed.clickRestored && destroyed.doubleRestored,
|
||||
`click ${destroyed.clickRestored}, double ${destroyed.doubleRestored}`,
|
||||
);
|
||||
|
||||
check(
|
||||
'no uncaught page errors across the run',
|
||||
errors.length === 0,
|
||||
errors.join(' | '),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
console.log(`RESULT: ${failures} failures`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
|
||||
/**
|
||||
* Find something on screen that a layer would select on click, so the ownership
|
||||
* guard can be proved against a real selection rather than a stub. Bundled
|
||||
* infrastructure first (no network, works keyless); CCTV cameras if they are up.
|
||||
*/
|
||||
async function findClickableTarget() {
|
||||
// CCTV first: it is the layer the brief names, and it goes through the shared
|
||||
// tracking click gesture that flights and military use too.
|
||||
const camera = await page
|
||||
.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
await manager.setEnabled('cctv', true, { origin: 'user' });
|
||||
const module = manager.layers.get('cctv')?.module;
|
||||
if (!module?.getUIState) return null;
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const cameras = module.getUIState()?.cameras || [];
|
||||
const found = cameras.find((entry) => {
|
||||
const lat = entry.lat ?? entry.latitude;
|
||||
const lon = entry.lon ?? entry.longitude;
|
||||
return Number.isFinite(lat) && Number.isFinite(lon);
|
||||
});
|
||||
if (found)
|
||||
return {
|
||||
id: found.id,
|
||||
lat: found.lat ?? found.latitude,
|
||||
lon: found.lon ?? found.longitude,
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (camera) {
|
||||
await page.evaluate((spot) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (spot.lon * Math.PI) / 180,
|
||||
latitude: (spot.lat * Math.PI) / 180,
|
||||
height: 600,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, camera);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
|
||||
);
|
||||
if (spot) return { ...spot, layer: 'CCTV camera' };
|
||||
}
|
||||
|
||||
// Bundled infrastructure as the keyless fallback: a different guarded handler,
|
||||
// the same shared claim, and always present without a key.
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'local-datacenters',
|
||||
true,
|
||||
{
|
||||
origin: 'user',
|
||||
},
|
||||
);
|
||||
const centreOf = (entity) => {
|
||||
const time = viewer.clock.currentTime;
|
||||
const point = entity.position?.getValue?.(time);
|
||||
if (point) return point;
|
||||
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
|
||||
if (hierarchy?.positions?.length) return hierarchy.positions[0];
|
||||
const line = entity.polyline?.positions?.getValue?.(time);
|
||||
return line?.length ? line[0] : null;
|
||||
};
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
? window.Cesium.Cartographic.fromCartesian(point)
|
||||
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
|
||||
if (!carto) continue;
|
||||
return {
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!site) return null;
|
||||
await page.evaluate((spot) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (spot.lon * Math.PI) / 180,
|
||||
latitude: (spot.lat * Math.PI) / 180,
|
||||
height: 2500,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates of the first entity matching `belongs` that is on screen
|
||||
* and clear of the side panels. Annotation marks and the draw tool's own
|
||||
* preview vertices are on screen too, so the predicate has to be specific.
|
||||
*/
|
||||
async function onScreen(belongs, timeout = 20_000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
(belongsSource) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const belongsTo = new Function(`return (${belongsSource})`)();
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const collections = [viewer.entities];
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
const source = viewer.dataSources.get(i);
|
||||
if (source?.name === 'gev-draw-preview') continue;
|
||||
collections.push(source.entities);
|
||||
}
|
||||
for (const source of collections) {
|
||||
for (const entity of source.values) {
|
||||
if (!belongsTo(entity)) continue;
|
||||
const position = entity.position?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
);
|
||||
if (!position) continue;
|
||||
const window2d = scene.cartesianToCanvasCoordinates(position);
|
||||
if (!window2d) continue;
|
||||
const { x, y } = window2d;
|
||||
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
|
||||
// Projecting into the window is not the same as being clickable:
|
||||
// the entity may be beyond the horizon or behind a building. Only a
|
||||
// spot where the scene actually picks it proves the control case.
|
||||
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
|
||||
if (!hit?.id || !belongsTo(hit.id)) continue;
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout, polling: 500 },
|
||||
belongs.toString(),
|
||||
)
|
||||
.then(
|
||||
(handle) => handle.jsonValue(),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
@@ -1265,9 +1265,10 @@ try {
|
||||
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
|
||||
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
|
||||
}
|
||||
const original = manager.setPanelCollapsed;
|
||||
const probe = { transitions: [], restore: () => { manager.setPanelCollapsed = original; } };
|
||||
manager.setPanelCollapsed = function (id, ...args) {
|
||||
const chrome = manager._panelChrome;
|
||||
const original = chrome.setPanelCollapsed;
|
||||
const probe = { transitions: [], restore: () => { chrome.setPanelCollapsed = original; } };
|
||||
chrome.setPanelCollapsed = function (id, ...args) {
|
||||
const before = document.getElementById(id)?.classList.contains('collapsed');
|
||||
const result = original.call(this, id, ...args);
|
||||
const after = document.getElementById(id)?.classList.contains('collapsed');
|
||||
@@ -1474,7 +1475,8 @@ try {
|
||||
getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }),
|
||||
});
|
||||
state.ids.push(transitionId);
|
||||
manager._renderToggles();
|
||||
const { application } = await import('/src/main.js');
|
||||
application.getComponents().data.presentation.panel._renderToggles();
|
||||
const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id;
|
||||
return {
|
||||
ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId,
|
||||
@@ -1514,12 +1516,13 @@ try {
|
||||
JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state }));
|
||||
await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) });
|
||||
if (label === 'OFF') {
|
||||
const passive = await page.evaluate((layerId) => {
|
||||
const passive = await page.evaluate(async (layerId) => {
|
||||
const probe = window.__qaDataFocus;
|
||||
const focusBefore = document.activeElement;
|
||||
const productionBefore = JSON.stringify(probe.production());
|
||||
const durableBefore = localStorage.getItem('gev:layer-state:v2');
|
||||
window.__godsEyeView.dataManager._refreshTogglePanel();
|
||||
const { application } = await import('/src/main.js');
|
||||
application.getComponents().data.presentation.refresh();
|
||||
return {
|
||||
focusRetained: document.activeElement === focusBefore
|
||||
&& focusBefore.matches('.data-toggle-btn')
|
||||
@@ -1625,7 +1628,8 @@ try {
|
||||
state.transition?.releaseDisable?.();
|
||||
const removed = [];
|
||||
for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id));
|
||||
manager._renderToggles();
|
||||
const { application } = await import('/src/main.js');
|
||||
application.getComponents().data.presentation.panel._renderToggles();
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false });
|
||||
// A native user-origin toggle legitimately asks the production state
|
||||
// coordinator to persist. Restore the exact pre-fixture value so this
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
// Composed-app media policy/lifecycle checks. Pinokio UA is a policy fixture,
|
||||
// not native-host acceptance; the YouTube SDK clock below is deterministic.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const output = path.resolve(
|
||||
process.env.QA_OUTPUT_DIR || 'qa-shots/nepal-media',
|
||||
);
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: process.env.QA_HEADFUL !== '1',
|
||||
args: process.env.QA_HEADFUL === '1' ? ['--use-angle=metal'] : [],
|
||||
});
|
||||
let assertions = 0;
|
||||
const check = (name, value) => {
|
||||
assert.ok(value, name);
|
||||
assertions++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
};
|
||||
try {
|
||||
for (const pinokio of [true, false]) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
if (pinokio)
|
||||
await page.setUserAgent((await browser.userAgent()) + ' Pinokio/8.0.40');
|
||||
const popups = [];
|
||||
page.on('popup', (popup) => popups.push(popup.url()));
|
||||
// Do not depend on provider availability to prove our startup/trim/exit handoff.
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (/youtube(?:-nocookie)?\.com\/embed\//.test(request.url())) {
|
||||
void request.respond({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<p>Player clock fixture</p>',
|
||||
});
|
||||
} else void request.continue();
|
||||
});
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__mediaFixture = { starts: [], stops: [] };
|
||||
window.YT = {
|
||||
Player: class {
|
||||
constructor(_frame, { events }) {
|
||||
this.events = events;
|
||||
this.began = null;
|
||||
this.stoppedAt = null;
|
||||
this.timer = setTimeout(
|
||||
() => events.onReady({ target: this }),
|
||||
1200,
|
||||
);
|
||||
}
|
||||
mute() {}
|
||||
playVideo() {
|
||||
if (this.began != null) return;
|
||||
this.began = Date.now();
|
||||
window.__mediaFixture.starts.push(this.began);
|
||||
this.events.onStateChange({ target: this, data: 1 });
|
||||
}
|
||||
getCurrentTime() {
|
||||
return this.began == null
|
||||
? 0
|
||||
: ((this.stoppedAt || Date.now()) - this.began) / 1000;
|
||||
}
|
||||
getPlayerState() {
|
||||
return this.began == null ? -1 : this.stoppedAt ? 2 : 1;
|
||||
}
|
||||
pauseVideo() {
|
||||
if (this.began != null && !this.stoppedAt) {
|
||||
this.stoppedAt = Date.now();
|
||||
window.__mediaFixture.stops.push(this.getCurrentTime());
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
clearTimeout(this.timer);
|
||||
this.pauseVideo();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const result = await page.evaluate(async (pinokio) => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find((s) => /Nepal/.test(s.title));
|
||||
const shot = scene.shots.find((s) => /Mailung Bazzar/.test(s.title));
|
||||
const following = scene.shots[scene.shots.indexOf(shot) + 1];
|
||||
const previous = scene.shots[scene.shots.indexOf(shot) - 1];
|
||||
const frames = () =>
|
||||
[...document.querySelectorAll('iframe')].map((f) => f.src);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
|
||||
const openingFrames = frames();
|
||||
await d.loadShot(scene.id, shot.id, { flyDuration: 0.1 });
|
||||
const passiveFrames = frames();
|
||||
const started = Date.now();
|
||||
const run = d.startScene(scene.id, {
|
||||
single: true,
|
||||
afterShotId: previous.id,
|
||||
preview: false,
|
||||
});
|
||||
let advanced = null;
|
||||
while (Date.now() - started < 25000) {
|
||||
if (d._selectedShotId === following.id) {
|
||||
advanced = Date.now() - started;
|
||||
break;
|
||||
}
|
||||
if (!d.running) break;
|
||||
await sleep(100);
|
||||
}
|
||||
const runningAtAdvance = d.running;
|
||||
const clipStops = [...window.__mediaFixture.stops];
|
||||
const status = document.querySelector('#scene-status')?.textContent;
|
||||
d.stopScene();
|
||||
await run;
|
||||
await sleep(250);
|
||||
const stoppedFrames = frames();
|
||||
let replayAutoplay = false;
|
||||
let replacementSilent = false;
|
||||
if (!pinokio) {
|
||||
await d.replayShot(scene.id, shot.id);
|
||||
replayAutoplay = frames().some((src) => /autoplay=1/.test(src));
|
||||
await d.loadShot(scene.id, scene.shots[0].id, { flyDuration: 0.1 });
|
||||
const starts = window.__mediaFixture.starts.length;
|
||||
await sleep(1600);
|
||||
replacementSilent =
|
||||
frames().length === 0 &&
|
||||
starts === window.__mediaFixture.starts.length;
|
||||
}
|
||||
const gl = d.viewer.scene.context._gl;
|
||||
const info = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
return {
|
||||
openingFrames,
|
||||
passiveFrames,
|
||||
advanced,
|
||||
runningAtAdvance,
|
||||
status,
|
||||
clipStops,
|
||||
stoppedFrames,
|
||||
replayAutoplay,
|
||||
replacementSilent,
|
||||
renderer: info
|
||||
? gl.getParameter(info.UNMASKED_RENDERER_WEBGL)
|
||||
: gl.getParameter(gl.RENDERER),
|
||||
};
|
||||
}, pinokio);
|
||||
console.log(JSON.stringify({ pinokioPolicyFixture: pinokio, ...result }));
|
||||
check(
|
||||
'opening shot creates no unrelated provider frames',
|
||||
result.openingFrames.length === 0,
|
||||
);
|
||||
check(
|
||||
'passive LOAD never requests autoplay',
|
||||
result.passiveFrames.every((src) => !/autoplay=1/.test(src)),
|
||||
);
|
||||
check(
|
||||
'actual next-shot identity reached while scene remains running',
|
||||
result.advanced > 0 && result.runningAtAdvance,
|
||||
);
|
||||
check('no scene timeout', !/timed out|Error:/i.test(result.status || ''));
|
||||
check('Stop removes provider resources', result.stoppedFrames.length === 0);
|
||||
check('no automatic external window', popups.length === 0);
|
||||
if (pinokio) {
|
||||
check(
|
||||
'suppressed providers allocate no iframe',
|
||||
result.passiveFrames.length === 0,
|
||||
);
|
||||
check(
|
||||
'fallback keeps authored dwell',
|
||||
result.advanced >= 7500 && result.advanced < 18000,
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'supported clip follows seven-second source clock',
|
||||
result.clipStops.some((sec) => sec >= 7 && sec < 7.6),
|
||||
);
|
||||
check(
|
||||
'explicit Play Shot still requests autoplay',
|
||||
result.replayAutoplay,
|
||||
);
|
||||
check(
|
||||
'replacement disowns pending player startup',
|
||||
result.replacementSilent,
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(output, pinokio ? 'pinokio-policy.png' : 'chrome.png'),
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
console.log(`PASS: ${assertions} assertions`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -85,6 +85,9 @@ try {
|
||||
);
|
||||
promptValue = '<b>QA shot</b>';
|
||||
await page.click('.scene-shot-label', { count: 2 });
|
||||
await page.waitForSelector('.scene-shot-rename', { visible: true });
|
||||
await page.type('.scene-shot-rename', promptValue);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('.scene-shot-label')?.textContent ===
|
||||
@@ -151,7 +154,20 @@ try {
|
||||
const projectFile = path.join(shots, 'project.json');
|
||||
fs.writeFileSync(projectFile, JSON.stringify(fixture));
|
||||
const input = await page.$('#scene-import-file');
|
||||
const beforePreview = await page.evaluate(() =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project),
|
||||
);
|
||||
await input.uploadFile(projectFile);
|
||||
await page.waitForSelector('[data-director-apply-import]');
|
||||
check(
|
||||
'Import preview leaves the current project untouched',
|
||||
await page.evaluate(
|
||||
(saved) =>
|
||||
JSON.stringify(window.__godsEyeView.sceneDirector._project) === saved,
|
||||
beforePreview,
|
||||
),
|
||||
);
|
||||
await page.click('[data-director-apply-import]');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('scene-status').textContent ===
|
||||
@@ -259,8 +275,8 @@ try {
|
||||
await input.uploadFile(badFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.getElementById('scene-status')
|
||||
.textContent.includes('invalid JSON'),
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('invalid JSON'),
|
||||
);
|
||||
check(
|
||||
'Invalid import reports failure and preserves the current project',
|
||||
@@ -271,6 +287,45 @@ try {
|
||||
'QA Scene',
|
||||
),
|
||||
);
|
||||
const savedBefore = await page.evaluate(() =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2'),
|
||||
);
|
||||
const futureFile = path.join(shots, 'future.json');
|
||||
fs.writeFileSync(futureFile, JSON.stringify({ version: 99, scenes: [] }));
|
||||
await input.uploadFile(futureFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('$.version'),
|
||||
);
|
||||
check(
|
||||
'Unsupported versions leave authored state and saved bytes unchanged',
|
||||
await page.evaluate(
|
||||
(saved) =>
|
||||
localStorage.getItem('godsEyeView.sceneProject.v2') === saved &&
|
||||
window.__godsEyeView.sceneDirector._getSelectedScene().title ===
|
||||
'QA Scene',
|
||||
savedBefore,
|
||||
),
|
||||
);
|
||||
const malformed = structuredClone(fixture);
|
||||
malformed.scenes[0].shots[0].camera.lat = 91;
|
||||
const malformedFile = path.join(shots, 'malformed.json');
|
||||
fs.writeFileSync(malformedFile, JSON.stringify(malformed));
|
||||
await input.uploadFile(malformedFile);
|
||||
await page.waitForFunction(() =>
|
||||
document
|
||||
.querySelector('[data-director-dialog] [role=status]')
|
||||
?.textContent.includes('camera.lat'),
|
||||
);
|
||||
check(
|
||||
'Invalid camera field identifies its path without replacing the project',
|
||||
await page.evaluate(
|
||||
(saved) => localStorage.getItem('godsEyeView.sceneProject.v2') === saved,
|
||||
savedBefore,
|
||||
),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.screenshot({ path: path.join(shots, 'desktop.png') });
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: path.join(shots, 'narrow.png') });
|
||||
|
||||
@@ -85,6 +85,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
const dm = gev.dataManager;
|
||||
await dm.setEnabled('traffic', true);
|
||||
const mod = dm.layers.get('traffic').module;
|
||||
const beforeUpdate = mod.getStats().lastUpdate;
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight —
|
||||
@@ -98,7 +99,9 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
|
||||
for (let i = 0; i < tS; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count >= minC && !s.loading) break;
|
||||
// Retained roads from the previous city can satisfy count/loading before
|
||||
// the move debounce starts its request. Require the destination render.
|
||||
if (s.lastUpdate !== beforeUpdate && s.count >= minC && !s.loading) break;
|
||||
}
|
||||
return s;
|
||||
}, view, minCount, timeoutS);
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/** Bound page promises as well as CDP itself; labels identify a stalled section. */
|
||||
export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
|
||||
const evaluate = page.evaluate.bind(page);
|
||||
page.evaluate = (fn, ...args) => {
|
||||
// The two long trace collectors pass their duration explicitly.
|
||||
const duration =
|
||||
args.find((v) => typeof v === 'number' && v >= 6000 && v <= 120000) || 0;
|
||||
const timeoutMs = Math.max(
|
||||
defaultTimeoutMs,
|
||||
duration ? duration + 5000 : 0,
|
||||
);
|
||||
const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
|
||||
return evaluate(
|
||||
async (source, values, timeoutMs, label) => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
// Puppeteer callbacks are standalone functions, just as in evaluate.
|
||||
Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Transit QA page wait timed out after ${timeoutMs} ms: ${label}`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
String(fn),
|
||||
args,
|
||||
timeoutMs,
|
||||
label,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportTransitVisibility(page, label) {
|
||||
const result = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
return {
|
||||
total: state._vehicles.size,
|
||||
visible: state._visible.size,
|
||||
moving: state._moving.size,
|
||||
held: state._renderHeld,
|
||||
requestRenderMode: app.viewer.scene.requestRenderMode,
|
||||
renderLoop: app.viewer.useDefaultRenderLoop,
|
||||
renderErrors: window.__transitRenderErrors || [],
|
||||
bounds: state._viewBounds,
|
||||
hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
|
||||
};
|
||||
});
|
||||
console.log(` ${label}: ${JSON.stringify(result)}`);
|
||||
if (result.renderLoop === false || result.renderErrors.length)
|
||||
throw new Error(
|
||||
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Intersect primed histories with the new snapshot; disappearance is availability. */
|
||||
export async function restoreRetainedTransit(retained) {
|
||||
if (!retained.length)
|
||||
return {
|
||||
error: 'No multi-fix history observed during the probe',
|
||||
unexercised: true,
|
||||
};
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
// enable() schedules proximity discovery; an empty pre-discovery map is not
|
||||
// evidence that every primed vehicle departed. Wait for this feed's poll.
|
||||
const deadline = Date.now() + 12000;
|
||||
while (state._activeFeeds && !state._vehicles.size && Date.now() < deadline)
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await layer.update();
|
||||
if (
|
||||
retained[0]?.primedAt &&
|
||||
Math.floor(Date.now() / 60000) !== Math.floor(retained[0].primedAt / 60000)
|
||||
)
|
||||
return {
|
||||
error: 'Reload crossed the priming minute; same-minute proof unavailable',
|
||||
unexercised: true,
|
||||
};
|
||||
const history = retained.find((h) =>
|
||||
state._vehicles.has(`${h.feedId || 'mbta'}:${h.vehicleId}`),
|
||||
);
|
||||
if (!history)
|
||||
return {
|
||||
error: `All ${retained.length} primed vehicles left the current snapshot`,
|
||||
unexercised: true,
|
||||
};
|
||||
const entry = state._vehicles.get(
|
||||
`${history.feedId || 'mbta'}:${history.vehicleId}`,
|
||||
);
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(entry.record.lon, entry.record.lat, 600),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
layer._transitPartsForTest().selection.selectVehicle(entry.key);
|
||||
return { key: entry.key, oldest: history.oldestT };
|
||||
}
|
||||
|
||||
export async function sampleTransitPixels(page, palette) {
|
||||
return page.evaluate(async (palette) => {
|
||||
const app = window.__godsEyeView;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
// Decode the actual billboard rasters before the paint being judged.
|
||||
const overlay = window.location
|
||||
? await import('/src/overlays/worldOverlay.js')
|
||||
: null;
|
||||
const rasters = new Map();
|
||||
await Promise.all(
|
||||
[
|
||||
...new Set(
|
||||
[...state._vehicles.values()]
|
||||
.map((e) => e.marker?.image)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].map(async (url) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
rasters.set(url, {
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
data: ctx.getImageData(0, 0, c.width, c.height).data,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
remove();
|
||||
reject(
|
||||
new Error('Transit pixel readback: no postRender within 5000 ms'),
|
||||
);
|
||||
}, 5000);
|
||||
const remove =
|
||||
window.__godsEyeView.viewer.scene.postRender.addEventListener(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
const scene = app.viewer.scene;
|
||||
const canvas = scene.canvas;
|
||||
const view = scene.camera.viewMatrix;
|
||||
const proj = scene.camera.frustum.projectionMatrix;
|
||||
const mul = (m, v) => [
|
||||
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
|
||||
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
|
||||
m[2] * v[0] + m[6] * v[1] + m[10] * v[2] + m[14] * v[3],
|
||||
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
|
||||
];
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
const sprites = [];
|
||||
const footprints = [];
|
||||
const intersects = (a, b) =>
|
||||
a.x < b.x + b.w &&
|
||||
a.x + a.w > b.x &&
|
||||
a.y < b.y + b.h &&
|
||||
a.y + a.h > b.y;
|
||||
const plates =
|
||||
app.styleManager
|
||||
?.getDetectionDiagnostics?.()
|
||||
?.calloutRects?.filter((r) => r.alpha > 0.01) || [];
|
||||
if (state._selectedKey) {
|
||||
const card = overlay?.getOverlayPaintRect(
|
||||
'transit-selected',
|
||||
state._selectedKey,
|
||||
);
|
||||
if (card) plates.push(card);
|
||||
}
|
||||
const panels = (overlay?.WORLD_OVERLAY_OCCLUDER_SELECTORS || [])
|
||||
.flatMap((selector) => [...document.querySelectorAll(selector)])
|
||||
.filter(
|
||||
(el) =>
|
||||
!el.hidden &&
|
||||
el.checkVisibility({
|
||||
checkOpacity: true,
|
||||
checkVisibilityCSS: true,
|
||||
}),
|
||||
)
|
||||
.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
||||
});
|
||||
const skipped = [];
|
||||
const style = state._stylePreset;
|
||||
const uniforms = app.styleManager?.stages?.[style]?.uniforms || {};
|
||||
const intensity = uniforms.intensity ?? 1;
|
||||
const pixelation =
|
||||
uniforms.pixelation ?? (style === 'surveillance' ? 2.5 : 1.5);
|
||||
const sensor = style === 'surveillance' || style === 'thermal';
|
||||
// Invert NVG's barrel transform; thermal only quantizes the source.
|
||||
const screenPoint = (sx, sy) => {
|
||||
let x = sx,
|
||||
y = sy;
|
||||
if (style === 'surveillance') {
|
||||
const tx = (sx / width) * 2 - 1,
|
||||
ty = (sy / height) * 2 - 1;
|
||||
let cx = tx,
|
||||
cy = ty;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const r2 = cx * cx + cy * cy;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
cx = tx / d;
|
||||
cy = ty / d;
|
||||
}
|
||||
x = ((cx + 1) * width) / 2;
|
||||
y = ((cy + 1) * height) / 2;
|
||||
}
|
||||
const offset = sensor
|
||||
? ((1 + (pixelation - 1) * intensity) * intensity) /
|
||||
2 /
|
||||
(canvas.width / width)
|
||||
: 0;
|
||||
return { x: x + offset, y: y - offset };
|
||||
};
|
||||
const uiBlocked = (x, y) => {
|
||||
const el = document.elementFromPoint?.(x, y);
|
||||
return (
|
||||
el &&
|
||||
el !== canvas &&
|
||||
!el.closest?.(
|
||||
'#world-overlay-root, #world-overlay-detection-surface',
|
||||
) &&
|
||||
el.tagName !== 'BODY' &&
|
||||
el.tagName !== 'HTML'
|
||||
);
|
||||
};
|
||||
for (const entry of state._vehicles.values()) {
|
||||
if (!entry.marker || entry.marker.show === false) continue;
|
||||
const p = entry.marker.position;
|
||||
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1]));
|
||||
if (!(clip[3] > 0)) continue;
|
||||
const screen = entry.marker.computeScreenSpacePosition?.(scene) || {
|
||||
x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
|
||||
y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
|
||||
};
|
||||
const rect = entry.marker.constructor.getScreenSpaceBoundingBox?.(
|
||||
entry.marker,
|
||||
screen,
|
||||
) || { x: screen.x - 16, y: screen.y - 16, width: 32, height: 32 };
|
||||
const { x, y } = screenPoint(screen.x, screen.y);
|
||||
const c = Math.abs(Math.cos(entry.marker.rotation || 0)),
|
||||
sn = Math.abs(Math.sin(entry.marker.rotation || 0));
|
||||
const w = rect.width * c + rect.height * sn,
|
||||
h = rect.height * c + rect.width * sn;
|
||||
const footprint = { x: x - w / 2, y: y - h / 2, w, h };
|
||||
footprints.push({ key: entry.key, ...footprint });
|
||||
let reason = null;
|
||||
if (
|
||||
rect.x < 0 ||
|
||||
rect.y < 0 ||
|
||||
rect.x + rect.width > width ||
|
||||
rect.y + rect.height > height
|
||||
)
|
||||
reason = 'billboard rectangle outside frame';
|
||||
if (
|
||||
entry.marker.color?.alpha < 0.95 ||
|
||||
entry.markerCollection?.show === false
|
||||
)
|
||||
reason = 'billboard not opaque/collection hidden';
|
||||
const raster = rasters.get(entry.marker.image);
|
||||
if (!raster) reason = 'billboard alpha unavailable';
|
||||
else if (
|
||||
raster.data[
|
||||
(Math.floor(raster.height / 2) * raster.width +
|
||||
Math.floor(raster.width / 2)) *
|
||||
4 +
|
||||
3
|
||||
] < 242
|
||||
)
|
||||
reason = 'billboard centre is transparent';
|
||||
if (
|
||||
sensor &&
|
||||
Math.hypot(
|
||||
(((x / width) * 2 - 1) * width) / height,
|
||||
(y / height) * 2 - 1,
|
||||
) > 0.6
|
||||
)
|
||||
reason = 'outside unmasked sensor lens';
|
||||
if (style === 'noir') {
|
||||
// Noir deliberately darkens the frame edges. Judge the core in
|
||||
// its unmasked field, independently of the measured brightness.
|
||||
const u = x / width,
|
||||
v = y / height;
|
||||
const transmission = Math.pow(
|
||||
16 * u * (1 - u) * v * (1 - v),
|
||||
0.3 + 0.4 * (uniforms.vignetteAmt ?? 0.5) * intensity,
|
||||
);
|
||||
if (1 - intensity + transmission * intensity < 0.9)
|
||||
reason = 'outside unmasked noir field';
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
[x, y],
|
||||
[rect.x, rect.y],
|
||||
[rect.x + rect.width, rect.y + rect.height],
|
||||
[rect.x + rect.width, rect.y],
|
||||
[rect.x, rect.y + rect.height],
|
||||
].some(([px, py]) => uiBlocked(px, py))
|
||||
)
|
||||
reason = 'UI panel overlaps billboard';
|
||||
if (panels.some((r) => intersects(footprint, r)))
|
||||
reason = 'UI panel rectangle overlaps billboard';
|
||||
if (plates.some((r) => intersects(footprint, r)))
|
||||
reason = 'DETECT label/card rectangle overlaps billboard';
|
||||
if (reason) {
|
||||
skipped.push({ key: entry.key, reason });
|
||||
continue;
|
||||
}
|
||||
if (x < 40 || y < 40 || x > width - 40 || y > height - 40) continue;
|
||||
sprites.push({
|
||||
key: entry.key,
|
||||
mode: entry.mode,
|
||||
x,
|
||||
y,
|
||||
selected: state._selectedKey === entry.key,
|
||||
screenX: screen.x,
|
||||
screenY: screen.y,
|
||||
rect,
|
||||
footprint,
|
||||
bracketX: entry.detectContact?._candidateScreenX ?? screen.x,
|
||||
bracketY: entry.detectContact?._candidateScreenY ?? screen.y,
|
||||
raster,
|
||||
rotation: entry.marker.rotation || 0,
|
||||
halfW: Number.isFinite(entry.marker.width)
|
||||
? Math.ceil(entry.marker.width / 2) + 2
|
||||
: 11,
|
||||
halfH: Number.isFinite(entry.marker.height)
|
||||
? Math.ceil(entry.marker.height / 2) + 2
|
||||
: 7,
|
||||
});
|
||||
}
|
||||
const isolated = sprites.filter(
|
||||
(a) =>
|
||||
!a.selected &&
|
||||
footprints.every(
|
||||
(b) => b.key === a.key || !intersects(a.footprint, b),
|
||||
),
|
||||
);
|
||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
const luma = (r, g, b) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
const readLuma = (cx, cy) => {
|
||||
const px = Math.floor(cx * scale);
|
||||
const py = canvas.height - 1 - Math.floor(cy * scale);
|
||||
const buf = new Uint8Array(4);
|
||||
gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
return luma(buf[0], buf[1], buf[2]);
|
||||
};
|
||||
const surface = document.getElementById('world-overlay-canvas');
|
||||
const ctx = surface?.getContext('2d');
|
||||
// A hidden surface has no client size; nothing to read there.
|
||||
const sscale =
|
||||
surface && surface.clientWidth > 0
|
||||
? surface.width / surface.clientWidth
|
||||
: 0;
|
||||
const out = [];
|
||||
for (const s of isolated) {
|
||||
if (out.length >= 12) break;
|
||||
// The normal canvas includes DETECT plates and selected cards.
|
||||
// A plate over the body invalidates this contact, even if WebGL below is hot.
|
||||
if (ctx && sscale > 0) {
|
||||
const body = ctx.getImageData(
|
||||
Math.floor((s.x - 3) * sscale),
|
||||
Math.floor((s.y - 3) * sscale),
|
||||
Math.max(1, Math.ceil(6 * sscale)),
|
||||
Math.max(1, Math.ceil(6 * sscale)),
|
||||
).data;
|
||||
if (body.some((v, i) => i % 4 === 3 && v > 0)) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason: 'DETECT label/card overlaps core',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Pick nine points from a solid patch of the source silhouette,
|
||||
// before examining framebuffer brightness. Avoid panels/window gaps.
|
||||
let corePoints = null,
|
||||
widestBand = -1;
|
||||
const cosCore = Math.cos(s.rotation),
|
||||
sinCore = Math.sin(s.rotation);
|
||||
// A tram's parallel sides have equally wide bands. Prefer its upper
|
||||
// body band over the geometric centre when those widths tie.
|
||||
const offsets =
|
||||
s.mode === 'tram'
|
||||
? [-0.2, 0.2, -0.1, 0.1, 0]
|
||||
: [0, -0.1, 0.1, -0.2, 0.2];
|
||||
for (const offset of offsets) {
|
||||
const candidates = [];
|
||||
let solid = true,
|
||||
bandWidth = Infinity;
|
||||
// Rank source geometry, never the observed framebuffer brightness.
|
||||
// A thin tram needs the widest opaque white band, with room for
|
||||
// the pixel filter on each side of the sampled patch.
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const ry = Math.floor(
|
||||
((dy + offset * s.rect.height) / s.rect.height + 0.5) *
|
||||
s.raster.height,
|
||||
);
|
||||
const middle = Math.floor(s.raster.width / 2);
|
||||
const white = (x) =>
|
||||
x >= 0 &&
|
||||
x < s.raster.width &&
|
||||
[0, 1, 2, 3].every(
|
||||
(c) =>
|
||||
s.raster.data[(ry * s.raster.width + x) * 4 + c] >= 240,
|
||||
);
|
||||
let left = middle,
|
||||
right = middle;
|
||||
while (white(left - 1)) left--;
|
||||
while (white(right + 1)) right++;
|
||||
bandWidth = Math.min(
|
||||
bandWidth,
|
||||
white(middle) ? right - left + 1 : 0,
|
||||
);
|
||||
}
|
||||
for (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const localY = dy + offset * s.rect.height;
|
||||
const rx = Math.floor(
|
||||
(dx / s.rect.width + 0.5) * s.raster.width,
|
||||
);
|
||||
const ry = Math.floor(
|
||||
(localY / s.rect.height + 0.5) * s.raster.height,
|
||||
);
|
||||
const i = (ry * s.raster.width + rx) * 4;
|
||||
if ([0, 1, 2, 3].some((c) => s.raster.data[i + c] < 240))
|
||||
solid = false;
|
||||
candidates.push(
|
||||
screenPoint(
|
||||
s.screenX + dx * cosCore + localY * sinCore,
|
||||
s.screenY - dx * sinCore + localY * cosCore,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (solid && bandWidth > widestBand) {
|
||||
corePoints = candidates;
|
||||
widestBand = bandWidth;
|
||||
}
|
||||
}
|
||||
if (!corePoints) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason: 'no solid silhouette interior for nine samples',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Picking is independent of rendered brightness: an overlapping
|
||||
// label or sprite must not masquerade as a dim sensor signature.
|
||||
// Check the whole sampled core, not just its centre pick.
|
||||
let coreOwned = true;
|
||||
if (scene.pick) {
|
||||
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
|
||||
for (const core of corePoints) {
|
||||
let u = core.x / width,
|
||||
v = 1 - core.y / height;
|
||||
if (style === 'surveillance') {
|
||||
const x = u * 2 - 1,
|
||||
y = v * 2 - 1,
|
||||
r2 = x * x + y * y;
|
||||
const d =
|
||||
1 + r2 * intensity * 0.25 + r2 * r2 * intensity * 0.075;
|
||||
u = (x * d + 1) / 2;
|
||||
v = (y * d + 1) / 2;
|
||||
}
|
||||
if (sensor) {
|
||||
const grid = 1 + (pixelation - 1) * intensity;
|
||||
u +=
|
||||
((Math.floor((u * canvas.width) / grid) * grid) /
|
||||
canvas.width -
|
||||
u) *
|
||||
intensity;
|
||||
v +=
|
||||
((Math.floor((v * canvas.height) / grid) * grid) /
|
||||
canvas.height -
|
||||
v) *
|
||||
intensity;
|
||||
}
|
||||
const picked = scene.pick(
|
||||
{ x: u * width, y: (1 - v) * height },
|
||||
1,
|
||||
1,
|
||||
);
|
||||
if (picked?.id !== s.key && picked?.primitive?.id !== s.key)
|
||||
coreOwned = false;
|
||||
}
|
||||
if (gl.bindFramebuffer)
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
||||
}
|
||||
if (!coreOwned) {
|
||||
skipped.push({
|
||||
key: s.key,
|
||||
reason:
|
||||
'sampled core is partially obscured or outside the sprite',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let centre = 0;
|
||||
for (const core of corePoints)
|
||||
centre += readLuma(core.x, core.y) / corePoints.length;
|
||||
let ringMin = 1;
|
||||
let ringMax = 0;
|
||||
// Sample the actual rotated raster halo, not a fixed-radius circle
|
||||
// that can hit the hot body or terrain after a display-size change.
|
||||
const raster = s.raster;
|
||||
const cos = Math.cos(s.rotation),
|
||||
sin = Math.sin(s.rotation);
|
||||
for (let ry = 1; ry < raster.height - 1; ry++)
|
||||
for (let rx = 1; rx < raster.width - 1; rx++) {
|
||||
const i = (ry * raster.width + rx) * 4;
|
||||
if (
|
||||
raster.data[i + 3] < 200 ||
|
||||
raster.data[i] > 30 ||
|
||||
raster.data[i + 1] > 30 ||
|
||||
raster.data[i + 2] > 30
|
||||
)
|
||||
continue;
|
||||
const dx = ((rx + 0.5) / raster.width - 0.5) * s.rect.width;
|
||||
const dy = ((ry + 0.5) / raster.height - 0.5) * s.rect.height;
|
||||
const q = screenPoint(
|
||||
s.screenX + dx * cos + dy * sin,
|
||||
s.screenY - dx * sin + dy * cos,
|
||||
);
|
||||
const v = readLuma(q.x, q.y);
|
||||
ringMin = Math.min(ringMin, v);
|
||||
ringMax = Math.max(ringMax, v);
|
||||
}
|
||||
let bracket = null;
|
||||
if (ctx && sscale > 0) {
|
||||
// Transit brackets follow the padded sprite extent and snapped
|
||||
// stroke centres. Sample the middle of each straight segment,
|
||||
// never a colour histogram containing antialiased edges/callouts.
|
||||
const cx = Math.floor(s.bracketX) + 0.5,
|
||||
cy = Math.floor(s.bracketY) + 0.5;
|
||||
const hw = s.halfW,
|
||||
hh = s.halfH,
|
||||
mid = Math.max(4, Math.floor(Math.min(hw, hh) * 0.55)) / 2;
|
||||
const points = [
|
||||
[cx - hw + mid, cy - hh],
|
||||
[cx + hw - mid, cy - hh],
|
||||
[cx - hw + mid, cy + hh],
|
||||
[cx + hw - mid, cy + hh],
|
||||
[cx - hw, cy - hh + mid],
|
||||
[cx + hw, cy - hh + mid],
|
||||
[cx - hw, cy + hh - mid],
|
||||
[cx + hw, cy + hh - mid],
|
||||
];
|
||||
let bestAlpha = 0;
|
||||
for (const [index, [x, y]] of points.entries()) {
|
||||
if (
|
||||
uiBlocked(x, y) ||
|
||||
plates.some((r) =>
|
||||
intersects({ x: x - 1, y: y - 1, w: 2, h: 2 }, r),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
// A label plate fills both sides of a stroke. Require empty
|
||||
// pixels beyond its dark backing on the perpendicular axis.
|
||||
const normal = index < 4 ? [0, 3] : [3, 0];
|
||||
if (
|
||||
[-1, 1].some(
|
||||
(sign) =>
|
||||
ctx.getImageData(
|
||||
Math.floor((x + sign * normal[0]) * sscale),
|
||||
Math.floor((y + sign * normal[1]) * sscale),
|
||||
1,
|
||||
1,
|
||||
).data[3] > 0,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const data = ctx.getImageData(
|
||||
Math.floor(x * sscale),
|
||||
Math.floor(y * sscale),
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
if (data[3] > bestAlpha) {
|
||||
bestAlpha = data[3];
|
||||
bracket = [data[0], data[1], data[2]];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
key: s.key,
|
||||
mode: s.mode,
|
||||
verified: true,
|
||||
rectangle: s.rect,
|
||||
centre,
|
||||
background:
|
||||
[
|
||||
readLuma(s.x + 25, s.y),
|
||||
readLuma(s.x - 25, s.y),
|
||||
readLuma(s.x, s.y + 25),
|
||||
readLuma(s.x, s.y - 25),
|
||||
].reduce((a, b) => a + b, 0) / 4,
|
||||
ringMin,
|
||||
ringMax,
|
||||
bracket,
|
||||
bracketTier: `transit_${s.mode}`,
|
||||
bracketExpected: palette[s.mode]
|
||||
? [1, 3, 5].map((i) =>
|
||||
parseInt(palette[s.mode].slice(i, i + 2), 16),
|
||||
)
|
||||
: null,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
});
|
||||
}
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
console.log(
|
||||
`TRANSIT_SAMPLER ${style}: ${out.length} verified; skipped=${JSON.stringify(skipped)}`,
|
||||
);
|
||||
resolve(out);
|
||||
});
|
||||
window.__godsEyeView.viewer.scene.requestRender();
|
||||
});
|
||||
}, palette);
|
||||
}
|
||||
|
||||
/** A fleet-to-fleet comparison must not inherit the city's selected marker. */
|
||||
export function readTransitFleetStyle({ key, resetSelection = false } = {}) {
|
||||
const layer = window.__godsEyeView.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest();
|
||||
if (resetSelection) layer._transitPartsForTest().selection.clearSelection();
|
||||
const entry = key
|
||||
? state._vehicles.get(key)
|
||||
: [...state._vehicles.values()].find((e) => e.marker?.show);
|
||||
return entry
|
||||
? {
|
||||
key: entry.key,
|
||||
selected: state._selectedKey === entry.key,
|
||||
color: entry.marker.color.toCssColorString(),
|
||||
width: entry.marker.width,
|
||||
image: entry.marker.image,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export const BOSTON = {
|
||||
id: 'boston',
|
||||
label: 'Boston (MBTA)',
|
||||
lat: 42.3554,
|
||||
lon: -71.0605,
|
||||
heading: 25,
|
||||
zone: 'America/New_York',
|
||||
};
|
||||
const LIVE_CITIES = [
|
||||
BOSTON,
|
||||
{
|
||||
id: 'austin',
|
||||
label: 'Austin (CapMetro)',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
heading: 340,
|
||||
zone: 'America/Chicago',
|
||||
},
|
||||
{
|
||||
id: 'minneapolis',
|
||||
label: 'Minneapolis (Metro Transit)',
|
||||
lat: 44.9778,
|
||||
lon: -93.265,
|
||||
heading: 0,
|
||||
zone: 'America/Chicago',
|
||||
},
|
||||
{
|
||||
id: 'helsinki',
|
||||
label: 'Helsinki (HSL)',
|
||||
lat: 60.1699,
|
||||
lon: 24.9384,
|
||||
heading: 0,
|
||||
zone: 'Europe/Helsinki',
|
||||
},
|
||||
{
|
||||
id: 'oslo',
|
||||
label: 'Oslo (Entur)',
|
||||
lat: 59.9139,
|
||||
lon: 10.7522,
|
||||
heading: 0,
|
||||
zone: 'Europe/Oslo',
|
||||
},
|
||||
];
|
||||
export function chooseLiveCities(now = new Date()) {
|
||||
return LIVE_CITIES.filter((city) => {
|
||||
const hour = Number(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: city.zone,
|
||||
hour: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
}).format(now),
|
||||
);
|
||||
return hour >= 6 && hour < 22;
|
||||
}).slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the post-FX style the way a user does — the preset button — and
|
||||
* fall back to the style manager only when the tray is not clickable, saying
|
||||
* so. Then set any style parameters through their sliders.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} name
|
||||
* @param {Record<string, number>} [params] aria-label → slider value
|
||||
* @returns {Promise<string>} how the style was set
|
||||
*/
|
||||
export async function selectStyle(page, name, params = {}, wait = pause) {
|
||||
let how = 'button';
|
||||
// The presets live in the bottom control panel, which auto-dismisses when
|
||||
// the pointer leaves it: pin it open the way a user does, once.
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('control-panel');
|
||||
if (panel && !panel.classList.contains('dock-pinned')) {
|
||||
document
|
||||
.querySelector('.dock-pin-btn[data-pin-target="control-panel"]')
|
||||
?.click();
|
||||
}
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed?.(
|
||||
'control-panel',
|
||||
false,
|
||||
{ explicit: true },
|
||||
);
|
||||
});
|
||||
await wait(400);
|
||||
try {
|
||||
await page.$eval(`.style-btn[data-style="${name}"]`, (el) =>
|
||||
el.scrollIntoView({ block: 'center' }),
|
||||
);
|
||||
await page.click(`.style-btn[data-style="${name}"]`);
|
||||
} catch {
|
||||
how = 'styleManager.setStyle (button not clickable)';
|
||||
await page.evaluate(
|
||||
(n) => window.__godsEyeView.styleManager.setStyle(n),
|
||||
name,
|
||||
);
|
||||
}
|
||||
// A click can be swallowed by a loading cover or a collapsed tray without
|
||||
// Puppeteer throwing. Verify the active style before querying generated rows.
|
||||
if (
|
||||
!(await page.evaluate(
|
||||
(n) => document.documentElement.dataset.gevStyle === n,
|
||||
name,
|
||||
))
|
||||
) {
|
||||
how = 'styleManager.setStyle (button did not activate style)';
|
||||
await page.evaluate(
|
||||
(n) => window.__godsEyeView.styleManager.setStyle(n),
|
||||
name,
|
||||
);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
(n) => document.documentElement.dataset.gevStyle === n,
|
||||
{ timeout: 10000 },
|
||||
name,
|
||||
);
|
||||
await wait(1_800);
|
||||
for (const [label, value] of Object.entries(params)) {
|
||||
const selector = `#param-sliders input.param-slider[aria-label="${label}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 10000 });
|
||||
await page.$eval(
|
||||
selector,
|
||||
(el, v) => {
|
||||
el.value = String(v);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
},
|
||||
value,
|
||||
);
|
||||
await wait(700);
|
||||
}
|
||||
return how;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn DETECT on through its own button, cycling until the dense profile is
|
||||
* reported on the detection surface.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @returns {Promise<string>} the profile reached
|
||||
*/
|
||||
export async function detectOn(page, wait = pause) {
|
||||
// The real button toggles OFF/restore. Density determines its on-profile.
|
||||
const mode = () =>
|
||||
page.evaluate(() => {
|
||||
const button = document.getElementById('detection-toggle');
|
||||
const label = button?.getAttribute('aria-label') || '';
|
||||
return {
|
||||
on: button?.getAttribute('aria-pressed') === 'true',
|
||||
mode: (label.split(':')[1] || 'off').trim().toUpperCase(),
|
||||
};
|
||||
});
|
||||
// The loading screen swallows the first click of a fresh page.
|
||||
await page
|
||||
.waitForFunction(() => !document.getElementById('loading-screen'), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.catch(() => {});
|
||||
// The DISPLAY rail starts collapsed on a first run: open it the way a user
|
||||
// does, then press DETECT until it reports on.
|
||||
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('detection-toggle')?.getBoundingClientRect()
|
||||
.width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
if (!(await mode()).on) {
|
||||
await page.click('#detection-toggle');
|
||||
await wait(300);
|
||||
}
|
||||
await page.$eval('#detection-density-slider', (el) => {
|
||||
el.value = '100';
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-label') === 'Detection overlay: dense',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
// Prove the button's actual OFF/restore cycle preserves DENSE.
|
||||
await page.click('#detection-toggle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-pressed') === 'false',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.click('#detection-toggle');
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.getElementById('detection-toggle')
|
||||
?.getAttribute('aria-label') === 'Detection overlay: dense',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
return (await mode()).mode;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||
export async function runTransitHeadingRegression(base, check) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=42.3601&lon=-71.0589&alt=1200&heading=0&pitch=-90`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?.layers?.has('transit'),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
layer._loadTransitFleetForTest(4, { lat: 42.3601, lon: -71.0589 }, 100);
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { destroyTrack } = await import('/src/data/contactPlayback.js');
|
||||
const {
|
||||
initializePlayback,
|
||||
recordFix,
|
||||
updatePlayback,
|
||||
attachFloorToPlace,
|
||||
} = await import('/src/layers/transit/movement.js');
|
||||
// The first three are parked: held east, reported east only, unknown.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
state._moving.delete(entry);
|
||||
destroyTrack(entry.track);
|
||||
initializePlayback(entry, state._historyBudget);
|
||||
recordFix(entry, {
|
||||
t: Date.now() - 30000,
|
||||
lat: entry.record.lat,
|
||||
lon: entry.record.lon,
|
||||
});
|
||||
attachFloorToPlace(entry, entry.record, 20);
|
||||
updatePlayback(entry, Date.now(), performance.now());
|
||||
entry.courseDeg = i === 0 ? 90 : null;
|
||||
entry.record.bearing = i === 1 ? 90 : null;
|
||||
entry.displayPaths.clear();
|
||||
parts.rendering.placeSample(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
}
|
||||
parts.rendering.syncRenderHold();
|
||||
window.__headingErrors = [];
|
||||
app.viewer.scene.renderError.addEventListener((scene, error) =>
|
||||
window.__headingErrors.push(error.message),
|
||||
);
|
||||
});
|
||||
const pose = async (heading) => {
|
||||
await page.evaluate((heading) => {
|
||||
const camera = window.__godsEyeView.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
|
||||
orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, heading);
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
};
|
||||
const sample = () =>
|
||||
page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const state = app.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitStateForTest();
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(r) =>
|
||||
m[r] * v[0] +
|
||||
m[r + 4] * v[1] +
|
||||
m[r + 8] * v[2] +
|
||||
m[r + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const clip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2,
|
||||
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2,
|
||||
};
|
||||
};
|
||||
return [0, 1, 2, 3].map((i) => {
|
||||
const entry = state._vehicles.get(`mbta:qa-${i}`);
|
||||
const p = project(entry.marker.position);
|
||||
return {
|
||||
rotation: entry.marker.rotation,
|
||||
course: entry.courseDeg,
|
||||
moving: state._moving.has(entry),
|
||||
visible: entry.marker.show,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
};
|
||||
});
|
||||
});
|
||||
await pose(0);
|
||||
const before = await sample();
|
||||
await pose(Math.PI / 2);
|
||||
const after = await sample();
|
||||
const deltaDeg = (a, b) =>
|
||||
(Math.atan2(Math.sin(b - a), Math.cos(b - a)) * 180) / Math.PI;
|
||||
for (const [i, label] of ['held course', 'reported bearing'].entries()) {
|
||||
const delta = deltaDeg(before[i].rotation, after[i].rotation);
|
||||
check(
|
||||
`parked ${label} reprojects through a 90 degree camera orbit`,
|
||||
before[i].visible &&
|
||||
after[i].visible &&
|
||||
!before[i].moving &&
|
||||
!after[i].moving &&
|
||||
Math.abs(delta - 90) <= 5,
|
||||
JSON.stringify({ before: before[i], after: after[i], delta }),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'unknown parked course remains screen-up',
|
||||
before[2].rotation === 0 && after[2].rotation === 0,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const moved = (await sample())[3];
|
||||
const dx = moved.x - after[3].x,
|
||||
dy = moved.y - after[3].y;
|
||||
const travelRotation = Math.atan2(-dx, -dy);
|
||||
const error = Math.abs(deltaDeg(travelRotation, moved.rotation));
|
||||
check(
|
||||
'moving vehicle still points along its rendered travel after orbit',
|
||||
moved.visible && moved.moving && Math.hypot(dx, dy) > 0.5 && error <= 5,
|
||||
JSON.stringify({
|
||||
pixels: Math.hypot(dx, dy),
|
||||
error,
|
||||
after: after[3],
|
||||
moved,
|
||||
}),
|
||||
);
|
||||
check(
|
||||
'heading regression has no scene render errors',
|
||||
(await page.evaluate(() => window.__headingErrors)).length === 0,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/** Camera-jump regression against an existing server; never starts a server. */
|
||||
import puppeteer from 'puppeteer';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4305';
|
||||
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
||||
await mkdir(shots, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
executablePath:
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(
|
||||
`${base}/?welcome=0#lat=30.271&lon=-97.7431&alt=600&heading=0&pitch=-60`,
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
window.__godsEyeView?.dataManager?.layers.has('transit'),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
return scene.globe.tilesLoaded && scene.globe.show;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const cover = document.getElementById('loading-screen');
|
||||
return !cover || cover.classList.contains('hidden');
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const app = window.__godsEyeView,
|
||||
camera = app.viewer.camera;
|
||||
const C = camera.positionCartographic.constructor,
|
||||
lat = 30.267 + 90 / 111320;
|
||||
const floor =
|
||||
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
floor + 600,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene,
|
||||
camera = app.viewer.camera;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const { getRegisteredTransitFeed } =
|
||||
await import('/src/data/transitFeeds.js');
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
const C = camera.positionCartographic.constructor;
|
||||
const feed = {
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-camera-recovery',
|
||||
};
|
||||
const aim = (lat, height = 600) =>
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
lat - 600 / Math.tan(Math.PI / 3) / 111320,
|
||||
height,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
// Model an outstanding floor lookup while reports arrive off screen.
|
||||
// Only acquisition is controlled; selection, visibility, paths and rendering
|
||||
// use their real owners. Release to the loaded terrain at the camera jump.
|
||||
const requestHeight = parts.height.requestHeight;
|
||||
parts.height.requestHeight = (lat, lon) => ({
|
||||
cell: `${lat},${lon}`,
|
||||
height: null,
|
||||
prior: null,
|
||||
});
|
||||
aim(30.28);
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = now - 135000 + i * 15000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'moving-bus',
|
||||
routeId: '1',
|
||||
tripId: 'qa',
|
||||
lat: 30.267 + (i * 90) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp: timestamp / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:moving-bus`);
|
||||
entry.qaFixture = true; // Protect this disclosed fixture from live feed eviction.
|
||||
parts.rendering.sampleIdle(entry);
|
||||
const before = {
|
||||
shown: entry.marker.show,
|
||||
heightPending: entry.heightPending,
|
||||
phase: entry.sample.phase,
|
||||
fixes: entry.track.count,
|
||||
};
|
||||
clearTimeout(state._floorTimer);
|
||||
state._floorTimer = null;
|
||||
const started = performance.now();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
const preparedAtSelection = !!parts.trails.diagnostics()?.body;
|
||||
parts.height.requestHeight = requestHeight;
|
||||
const floor =
|
||||
scene.globe.getHeight(
|
||||
C.fromDegrees(entry.sample.lon, entry.sample.lat),
|
||||
) || 0;
|
||||
aim(entry.sample.lat, floor + 600);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let trail;
|
||||
while (performance.now() - started < 1400) {
|
||||
trail = parts.trails.diagnostics();
|
||||
if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
|
||||
scene.requestRender();
|
||||
await wait(25);
|
||||
}
|
||||
const status = {
|
||||
before,
|
||||
preparedAtSelection,
|
||||
markerShow: entry.marker.show,
|
||||
body: !!trail?.body,
|
||||
bodyShow: trail?.body?.show === true,
|
||||
visibility: layer
|
||||
._transitVisibilityForTest()
|
||||
.find((e) => e.key === entry.key),
|
||||
recoveryMs: performance.now() - started,
|
||||
};
|
||||
if (!entry.marker.show || !trail?.body?.show || !trail.body.ready)
|
||||
return {
|
||||
...status,
|
||||
reason: 'marker or body did not recover before pixel deadline',
|
||||
};
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
const V = entry.marker.position.constructor;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
const canvas = scene.canvas,
|
||||
gl = scene.context._gl,
|
||||
scale = canvas.width / canvas.clientWidth;
|
||||
const mul = (m, v) =>
|
||||
[0, 1, 2, 3].map(
|
||||
(i) =>
|
||||
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
|
||||
);
|
||||
const project = (p) => {
|
||||
const q = mul(
|
||||
camera.frustum.projectionMatrix,
|
||||
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]),
|
||||
);
|
||||
return {
|
||||
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
|
||||
inFront: q[3] > 0,
|
||||
};
|
||||
};
|
||||
const samples = Array.from({ length: 8 }, (_, i) => {
|
||||
const d = (lengths.at(-1) * (i + 1)) / 9;
|
||||
let j = 1;
|
||||
while (j < lengths.length - 1 && lengths[j] < d) j++;
|
||||
const p = V.lerp(
|
||||
points[j - 1],
|
||||
points[j],
|
||||
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
|
||||
new V(),
|
||||
);
|
||||
const cart = C.fromCartesian(p);
|
||||
cart.height = scene.globe.getHeight(cart) || 0;
|
||||
return {
|
||||
...project(C.toCartesian(cart)),
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const sprite = {
|
||||
...project(entry.marker.position),
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
px < r ||
|
||||
py < r ||
|
||||
px + r >= canvas.width ||
|
||||
py + r >= canvas.height
|
||||
)
|
||||
return null;
|
||||
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
|
||||
gl.readPixels(
|
||||
px - r,
|
||||
py - r,
|
||||
2 * r + 1,
|
||||
2 * r + 1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
data,
|
||||
);
|
||||
return Array.from(data);
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('No recovery frame'));
|
||||
}, 500);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timer);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
let visible = true;
|
||||
const remove = scene.preRender.addEventListener(() => {
|
||||
trail.body.show = visible;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = visible && trail.head.show;
|
||||
});
|
||||
const captures = [];
|
||||
try {
|
||||
for (const show of [false, true, false, true]) {
|
||||
visible = show;
|
||||
await frame();
|
||||
captures.push(await frame());
|
||||
}
|
||||
} finally {
|
||||
remove();
|
||||
parts.trails.update();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures;
|
||||
return {
|
||||
...status,
|
||||
samples,
|
||||
sprite,
|
||||
off,
|
||||
on,
|
||||
offAgain,
|
||||
onAgain,
|
||||
tilesReady:
|
||||
scene.globe.tilesLoaded &&
|
||||
parts.trails.diagnostics().body === trail.body,
|
||||
elapsedMs: performance.now() - started,
|
||||
attributes: Array.from({ length: 4 }, (_, i) => {
|
||||
const a = trail.body.getGeometryInstanceAttributes(`${i}:1`);
|
||||
return { show: Array.from(a.show), color: Array.from(a.color) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.present = pixels.present;
|
||||
result.pixelPass = pixels.pass;
|
||||
result.pixelDetails = pixels;
|
||||
result.errors = errors;
|
||||
await page.screenshot({ path: `${shots}/recovery.png` });
|
||||
await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2));
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
const pass =
|
||||
result.before?.shown === false &&
|
||||
result.before?.phase === 'playing' &&
|
||||
result.preparedAtSelection &&
|
||||
result.markerShow &&
|
||||
result.body &&
|
||||
result.bodyShow &&
|
||||
result.elapsedMs <= 2000 &&
|
||||
pixels.pass &&
|
||||
!errors.length;
|
||||
console.log(
|
||||
`${pass ? 'PASS' : 'FAIL'} select moving bus, setView, marker + body + >=6/8 trail pixels within 2 s`,
|
||||
);
|
||||
console.log(JSON.stringify(result));
|
||||
process.exitCode = pass ? 0 : 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Credentialed AI voice acceptance using a prerecorded Chromium microphone.
|
||||
*
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189
|
||||
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -11,8 +11,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const appUrl = process.argv[2] || 'http://localhost:4189';
|
||||
const wavPath = process.argv[3]
|
||||
const pushToTalk = process.argv.includes('--push-to-talk');
|
||||
const positional = process.argv.slice(2).filter(arg => arg !== '--push-to-talk');
|
||||
const targetUrl = new URL(positional[0] || 'http://localhost:4189');
|
||||
// Keep first-run focus changes outside this voice/keyboard acceptance test.
|
||||
targetUrl.searchParams.set('welcome', '0');
|
||||
const appUrl = targetUrl.href;
|
||||
const wavPath = positional[1]
|
||||
|| path.join(repoRoot, 'scripts', 'fixtures', 'voice', 'full-globe-turn-on-radio.wav');
|
||||
const expectedFixtureSha256 = 'b57af70db1922b72fec2c6c58348ccd3309e10aa1e8edec2890277dff26cc7bb';
|
||||
|
||||
@@ -60,6 +65,7 @@ try {
|
||||
await page.waitForFunction(() => (
|
||||
window.__godsEyeView?.voiceCommands
|
||||
&& document.getElementById('gev-voice-button')
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden')
|
||||
), { timeout: 30_000 });
|
||||
|
||||
const readState = () => page.evaluate(() => {
|
||||
@@ -81,7 +87,26 @@ try {
|
||||
});
|
||||
|
||||
const initial = await readState();
|
||||
await page.evaluate(() => document.getElementById('gev-voice-button').click());
|
||||
let pushToTalkStarted = false;
|
||||
let microphoneReleased = false;
|
||||
if (pushToTalk) {
|
||||
await page.bringToFront();
|
||||
await page.focus('.cesium-widget canvas');
|
||||
await page.keyboard.down('Space');
|
||||
await page.waitForFunction(() => document.querySelector(
|
||||
'[data-push-to-talk="held"][data-microphone="active"]',
|
||||
), { timeout: 30_000 });
|
||||
pushToTalkStarted = true;
|
||||
// The hash-pinned fixture is 9.2 seconds long and plays once from mic acquisition.
|
||||
await new Promise(resolve => setTimeout(resolve, 10_000));
|
||||
await page.keyboard.up('Space');
|
||||
microphoneReleased = await page.evaluate(() => {
|
||||
const status = window.__godsEyeView.voiceCommands.status;
|
||||
return status === 'idle' || Boolean(document.querySelector('[data-microphone="muted"]'));
|
||||
});
|
||||
} else {
|
||||
await page.click('#gev-voice-button');
|
||||
}
|
||||
|
||||
const timeline = [initial];
|
||||
let lastSignature = JSON.stringify(initial);
|
||||
@@ -113,10 +138,14 @@ try {
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: finalState.voiceStatus === 'idle'
|
||||
ok: (!pushToTalk || (pushToTalkStarted && microphoneReleased))
|
||||
&& finalState.voiceStatus === 'idle'
|
||||
&& finalState.radioAudioState === 'playing'
|
||||
&& finalState.radioVoiceDucked === false
|
||||
&& Number(finalState.cameraHeightM) >= 10_000_000,
|
||||
mode: pushToTalk ? 'push-to-talk' : 'click',
|
||||
pushToTalkStarted,
|
||||
microphoneReleased,
|
||||
fixture: wavPath,
|
||||
fixtureSha256,
|
||||
appUrl,
|
||||
|
||||
@@ -4,9 +4,10 @@ import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { parseEnv } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { projectRoot } from './project-root.mjs';
|
||||
import { selectMapStartupRoute } from '../src/mapStartup.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ROOT = projectRoot(import.meta.url);
|
||||
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
|
||||
const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']);
|
||||
|
||||
@@ -174,7 +175,7 @@ export function buildCapabilitySummary(
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false } = {}) {
|
||||
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
|
||||
const node = classifyNodeVersion();
|
||||
const npm = npmProcessSpec();
|
||||
const npmResult = spawnSync(npm.command, ['--version'], {
|
||||
@@ -183,9 +184,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
});
|
||||
const credentials = Object.fromEntries(CREDENTIALS.map((spec) => [
|
||||
spec.name,
|
||||
resolveCredential(spec, { includeKeychain, authoritativeEnvironment }),
|
||||
resolveCredential(spec, { includeKeychain, authoritativeEnvironment, rootDir }),
|
||||
]));
|
||||
const dependenciesInstalled = hasRequiredDependencies();
|
||||
const dependenciesInstalled = hasRequiredDependencies(rootDir);
|
||||
return {
|
||||
ready: node.level !== 'error' && npmResult.status === 0 && dependenciesInstalled,
|
||||
node: { version: process.versions.node, ...node },
|
||||
@@ -195,7 +196,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
dependenciesInstalled,
|
||||
credentials,
|
||||
capabilities: buildCapabilitySummary(credentials, {
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment }),
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+159
-69
@@ -85,6 +85,7 @@
|
||||
* Exits non-zero if ANY invariant fails. DOES NOT COMMIT anything.
|
||||
*
|
||||
* Flags:
|
||||
* --source-base <path> Browser module prefix (default /src)
|
||||
* --url <url> App URL (default http://localhost:4173)
|
||||
* --headful Show the browser (debugging)
|
||||
* --keep-open Leave the browser open after the run (debugging)
|
||||
@@ -105,6 +106,7 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, '');
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
@@ -249,6 +251,7 @@ async function main() {
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument((base) => { window.__gevQaSourceBase = base; }, SOURCE_BASE);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
@@ -2655,7 +2658,7 @@ async function main() {
|
||||
const arrival = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const { screenProjectedRotation } = await import('/src/data/iconOrientation.js');
|
||||
const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
|
||||
// 3D models OFF for this phase: a model-handed-off billboard is hidden
|
||||
// and skips rotation updates entirely — the probes need live billboards
|
||||
// (this is also the app's default state the field report came from).
|
||||
@@ -2857,6 +2860,55 @@ async function main() {
|
||||
tick();
|
||||
setTimeout(() => { done = true; res(); }, ms);
|
||||
});
|
||||
// A timer can expire while the camera is new but the model/cache still
|
||||
// belongs to the old frame. Observe the first completed exit frame,
|
||||
// including a wrong result; never wait for visibility or height to pass.
|
||||
window.__dfObserveTrackedExit = async ({ scene, trackedEntity, trackedModel,
|
||||
getTrackedModel, getTrackedEntity, getTrackedId, getCameraHeight,
|
||||
exitHeight, read, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) => {
|
||||
const deadline = now() + 5000;
|
||||
let removePre, removePost, removeError, timer;
|
||||
let preFrame = null;
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
let finished = false;
|
||||
const finish = (result) => { if (!finished) { finished = true; resolve(result); } };
|
||||
const valid = () => {
|
||||
try {
|
||||
if (now() > deadline) return 'tracked exit observation exceeded its 5000 ms deadline';
|
||||
if (getTrackedEntity() !== trackedEntity || getTrackedId() !== 'aaa097') return 'tracked exit fixture identity changed';
|
||||
if (!trackedModel || getTrackedModel() !== trackedModel) return 'tracked exit model identity changed';
|
||||
if (!(getCameraHeight() > exitHeight)) return 'tracked exit camera left the exit regime';
|
||||
return null;
|
||||
} catch { return 'tracked exit fixture validation failed'; }
|
||||
};
|
||||
removePre = scene.preUpdate.addEventListener(() => {
|
||||
if (finished) return;
|
||||
const error = valid();
|
||||
if (error) { finish({ error }); return; }
|
||||
if (preFrame == null) preFrame = scene.frameState.frameNumber;
|
||||
});
|
||||
removePost = scene.postRender.addEventListener(() => {
|
||||
if (finished || preFrame == null) return;
|
||||
const error = valid();
|
||||
if (error) { finish({ error }); return; }
|
||||
if (scene.frameState.frameNumber === preFrame) {
|
||||
finish({ error: 'tracked exit observation has no new rendered frame' }); return;
|
||||
}
|
||||
try {
|
||||
const snapshot = read();
|
||||
finish(snapshot ? { ...snapshot, observationFrame: scene.frameState.frameNumber }
|
||||
: { error: 'tracked exit snapshot missing' });
|
||||
} catch { finish({ error: 'tracked exit snapshot failed' }); }
|
||||
});
|
||||
removeError = scene.renderError.addEventListener(() => finish({ error: 'tracked exit render failed' }));
|
||||
timer = setTimer(() => finish({ error: 'tracked exit update did not complete within 5000 ms' }), 5000);
|
||||
scene.requestRender();
|
||||
});
|
||||
} finally {
|
||||
clearTimer(timer); removePre?.(); removePost?.(); removeError?.();
|
||||
}
|
||||
};
|
||||
// Cesium Models in the scene, split by visibility (duck-typed the same
|
||||
// way the harness excludes them from height probes).
|
||||
//
|
||||
@@ -2937,7 +2989,7 @@ async function main() {
|
||||
// Read the thresholds from the app's own policy module so a later retune
|
||||
// of the swap distance moves these pins with it rather than stranding
|
||||
// them on stale numbers.
|
||||
const reg = await import('/src/data/trackedModelRegime.js');
|
||||
const reg = await import(`${window.__gevQaSourceBase || '/src'}/data/trackedModelRegime.js`);
|
||||
window.__dfRegime = {
|
||||
enter: reg.TRACKED_MODEL_ENTER_ALT_M,
|
||||
exit: reg.TRACKED_MODEL_EXIT_ALT_M,
|
||||
@@ -2983,17 +3035,12 @@ async function main() {
|
||||
}
|
||||
return window.__dfCountModels(icao);
|
||||
};
|
||||
// Vite serves an edited source file as `…/groundFloor.js?t=<hmr stamp>`;
|
||||
// importing the PLAIN path then hands back a second, unrelated module
|
||||
// instance whose cells the app never reads. Offer the URL the app itself
|
||||
// loaded first, then the plain path (clean, never-hot-reloaded server).
|
||||
const seen = performance.getEntriesByType('resource')
|
||||
.map((e) => e.name)
|
||||
.filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n));
|
||||
window.__dfCandidates = [...new Set([...seen.reverse(), '/src/data/groundFloor.js'])];
|
||||
// Read the application's surface owner, then prove it is the one
|
||||
// used by the actual poll/render path with the unchanged floor seed.
|
||||
window.__dfCandidates = window.__godsEyeView.surfaceServices?.groundFloor ? ['application surface'] : [];
|
||||
return { candidates: window.__dfCandidates.length };
|
||||
});
|
||||
record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0,
|
||||
record('display-floor: ground-floor service owner found', dfSetup.candidates > 0,
|
||||
JSON.stringify(dfSetup));
|
||||
|
||||
// Identity probe. Seed a contact's FIX cell BEFORE its first fix arrives,
|
||||
@@ -3009,8 +3056,7 @@ async function main() {
|
||||
const tried = [];
|
||||
for (let i = 0; i < window.__dfCandidates.length; i++) {
|
||||
const url = window.__dfCandidates[i];
|
||||
let gf;
|
||||
try { gf = await import(/* @vite-ignore */ url); } catch { tried.push({ url, h: null }); continue; }
|
||||
const gf = window.__godsEyeView.surfaceServices.groundFloor;
|
||||
if (typeof gf.reportMeshFloorCell !== 'function') { tried.push({ url, h: null }); continue; }
|
||||
gf.setMeshFloorPreferred(true);
|
||||
gf._clearMeshFloorCellsForTest();
|
||||
@@ -3287,9 +3333,11 @@ async function main() {
|
||||
const bbBefore = window.__dfFindBB('aaa097');
|
||||
if (!bbBefore) return { error: 'aaa097 billboard missing' };
|
||||
const d0 = window.__dfCarto(bbBefore.position);
|
||||
// Plant a floor well ABOVE where it currently renders, across the block
|
||||
// it can move within, so an UNFLOORED tracked entity is unmistakable.
|
||||
const seeded = d0.h + 40;
|
||||
// Plant above both the current billboard and this group's 400 m
|
||||
// identity-probe floor. A poll can refresh the raw render altitude
|
||||
// after d0 was read; a lower seed makes the negative model-ownership
|
||||
// assertion impossible even when the clamp correctly stands aside.
|
||||
const seeded = Math.max(d0.h, 400) + 40;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
gf.reportMeshFloorCell(cell(d0.lat) + dy * 0.001, cell(d0.lon) + dx * 0.001, seeded);
|
||||
@@ -3379,10 +3427,30 @@ async function main() {
|
||||
// the billboard — which MUST be floored, or a tracked grounded contact
|
||||
// renders buried at any distance the operator pulls out to. This is the
|
||||
// coverage the toggle used to reach; the zoom regime reaches it now.
|
||||
const findOwnModel = () => {
|
||||
let found = null;
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (typeof item?.get === 'function' && typeof item.length === 'number') walk(item);
|
||||
else if (item?.id === 'aaa097' && item.activeAnimations !== undefined
|
||||
&& item.minimumPixelSize !== undefined) found = item;
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return found;
|
||||
};
|
||||
const trackedEntity = v.trackedEntity;
|
||||
const trackedModel = findOwnModel();
|
||||
const achievedOut = await window.__dfZoomAbove(window.__dfRegime.exit + 5000);
|
||||
await window.__dfSettle(1500);
|
||||
const zoomedOut = readTracked();
|
||||
if (!zoomedOut) return { error: 'tracked entity has no position (zoomed out)' };
|
||||
const zoomedOut = await window.__dfObserveTrackedExit({
|
||||
scene: v.scene, trackedEntity, trackedModel, getTrackedModel: findOwnModel,
|
||||
getTrackedEntity: () => v.trackedEntity,
|
||||
getTrackedId: () => fl.getTrackedInfo()?.icao24,
|
||||
getCameraHeight: window.__dfCamH, exitHeight: window.__dfRegime.exit,
|
||||
read: readTracked,
|
||||
});
|
||||
if (zoomedOut.error) return { error: zoomedOut.error };
|
||||
|
||||
const out = {
|
||||
rawH: d0.h,
|
||||
@@ -3644,11 +3712,10 @@ async function main() {
|
||||
}
|
||||
|
||||
// ---- F3: the LOADING window is billboard-owned, not model-owned ------
|
||||
// A fleet model is registered with Cesium's default show=true the instant
|
||||
// its glTF resolves, but the handoff waits for `ready`; and a tracked
|
||||
// model is null for the whole load even though the regime is already
|
||||
// active. Both windows leave the BILLBOARD as the visual, so both must
|
||||
// stay floored — ownership means actually rendering.
|
||||
// Stress the fleet ownership predicate with synthetic show=true/ready=false
|
||||
// flags. Production admission keeps the model hidden; these shadows do
|
||||
// not simulate Cesium's internal loading/draw state. The tracked check
|
||||
// below separately samples the no-rendering-model window.
|
||||
const dfLoading = await evalPage(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
@@ -3664,13 +3731,8 @@ async function main() {
|
||||
};
|
||||
const out = {};
|
||||
|
||||
// (a) FLEET: reach the real admitted-but-not-ready window. A fleet model
|
||||
// is registered in `_models` with Cesium's default show=true the moment
|
||||
// its glTF resolves, while the billboard handoff waits for `ready` — so
|
||||
// for a tick the model claims the visual it is not drawing. That window
|
||||
// is ~one fleet tick wide, so hold the state open by shadowing the two
|
||||
// flags on the instance (the same defineProperty trick the ground-3d
|
||||
// group uses on `tilesLoaded`).
|
||||
// (a) FLEET: retain the adversarial flags until this contact has passed
|
||||
// through its installed fleet update and that frame has completed.
|
||||
const findFleetModel = (id) => {
|
||||
let found = null;
|
||||
const walk = (coll) => {
|
||||
@@ -3699,8 +3761,26 @@ async function main() {
|
||||
// assigns `model.show = false` for a not-ready model, and assigning to a
|
||||
// non-writable own property throws in strict mode — which lands inside
|
||||
// Cesium's render loop and stops rendering for the rest of the run.
|
||||
const priorReady = Object.getOwnPropertyDescriptor(fleetModel, 'ready');
|
||||
const priorShow = Object.getOwnPropertyDescriptor(fleetModel, 'show');
|
||||
let armed = false;
|
||||
let processedFrame = null;
|
||||
let sample = null;
|
||||
let removePostRender = null;
|
||||
try {
|
||||
Object.defineProperty(fleetModel, 'ready', { get: () => false, set: () => {}, configurable: true });
|
||||
Object.defineProperty(fleetModel, 'show', { get: () => true, set: () => {}, configurable: true });
|
||||
Object.defineProperty(fleetModel, 'show', {
|
||||
get: () => true,
|
||||
set: (value) => {
|
||||
// Both the not-ready handoff and horizon cull clear show AFTER
|
||||
// computing this contact's display floor. Neither a timer nor an
|
||||
// unrelated rendered frame proves that this aircraft was processed.
|
||||
if (armed && value === false && processedFrame == null) {
|
||||
processedFrame = v.scene.frameState.frameNumber;
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const bb = window.__dfFindBB('aaa097');
|
||||
if (!bb) return { error: 'aaa097 billboard missing' };
|
||||
bb.show = true; // the handoff would not have hidden it: the model is not ready
|
||||
@@ -3714,21 +3794,45 @@ async function main() {
|
||||
fl._clearDisplayFloorStateForTest();
|
||||
const seededFleet = d0.h + 45;
|
||||
floorAround(d0, seededFleet);
|
||||
// reportMeshFloorCell is a direct test seam and does not schedule a
|
||||
// Cesium frame. Force the CallbackProperty to re-evaluate before the
|
||||
// assertion, matching the request-render fix in the retained-model
|
||||
// scenario above.
|
||||
v.scene.requestRender();
|
||||
await window.__dfSettle(900);
|
||||
const bbAfter = window.__dfFindBB('aaa097');
|
||||
out.fleetModelReady = fleetModel.ready;
|
||||
out.fleetModelShow = fleetModel.show;
|
||||
out.fleetBillboardVisible = !!bbAfter?.show;
|
||||
out.fleetSeeded = seededFleet;
|
||||
out.fleetH = bbAfter ? window.__dfCarto(bbAfter.position).h : null;
|
||||
delete fleetModel.ready;
|
||||
delete fleetModel.show;
|
||||
fl.setParams({ models3d: false });
|
||||
const tickDeadline = Date.now() + 5000;
|
||||
removePostRender = v.scene.postRender.addEventListener(() => {
|
||||
if (sample || processedFrame == null || v.scene.frameState.frameNumber < processedFrame) return;
|
||||
if (Date.now() > tickDeadline) {
|
||||
sample = { error: 'fleet loading fixture completed after its 5000 ms deadline' };
|
||||
return;
|
||||
}
|
||||
const currentBb = window.__dfFindBB('aaa097');
|
||||
const currentModel = findFleetModel('aaa097');
|
||||
sample = {
|
||||
fleetTickCompleted: true,
|
||||
fleetFrame: v.scene.frameState.frameNumber,
|
||||
fleetModelReady: fleetModel.ready,
|
||||
fleetModelShow: fleetModel.show,
|
||||
fleetBillboardVisible: !!currentBb?.show,
|
||||
fleetSeeded: seededFleet,
|
||||
fleetH: currentBb ? window.__dfCarto(currentBb.position).h : null,
|
||||
};
|
||||
if (currentBb !== bb || currentModel !== fleetModel || fleetModel.isDestroyed()
|
||||
|| fl.getTrackedInfo()) sample.error = 'fleet loading fixture changed before its completed frame';
|
||||
});
|
||||
armed = true;
|
||||
// This is a setup-completion deadline, not a visual latency budget.
|
||||
// Take the FIRST completed fleet result, even when its height is wrong;
|
||||
// never poll the height until the assertion happens to pass.
|
||||
do {
|
||||
await window.__dfSettle(50);
|
||||
} while (!sample && Date.now() < tickDeadline);
|
||||
if (!sample) return { error: 'fleet loading fixture did not complete an aircraft update within 5000 ms' };
|
||||
Object.assign(out, sample);
|
||||
} finally {
|
||||
armed = false;
|
||||
removePostRender?.();
|
||||
if (priorReady) Object.defineProperty(fleetModel, 'ready', priorReady);
|
||||
else delete fleetModel.ready;
|
||||
if (priorShow) Object.defineProperty(fleetModel, 'show', priorShow);
|
||||
else delete fleetModel.show;
|
||||
fl.setParams({ models3d: false });
|
||||
}
|
||||
await window.__dfSettle(400);
|
||||
|
||||
// (b) TRACKED, regime ACTIVE but no model yet. Turning 3D on while
|
||||
@@ -3772,7 +3876,8 @@ async function main() {
|
||||
skip('display-floor/loading: fleet model loading window', dfLoading.skipped);
|
||||
} else {
|
||||
record('display-floor/loading: a fleet model that is shown-but-not-ready does not own the visual',
|
||||
!dfLoading.error && dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
|
||||
!dfLoading.error && dfLoading.fleetTickCompleted === true
|
||||
&& dfLoading.fleetModelShow === true && dfLoading.fleetModelReady === false
|
||||
&& dfLoading.fleetBillboardVisible === true
|
||||
&& Number.isFinite(dfLoading.fleetH)
|
||||
&& dfLoading.fleetH >= dfLoading.fleetSeeded + DISPLAY_FLOOR_LIFT_M - 0.5,
|
||||
@@ -3808,27 +3913,11 @@ async function main() {
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
// `.module` is the layer OBJECT (the default export), not the module
|
||||
// namespace, so the handoff seam is not on it. Reach the namespace the
|
||||
// same way this group reaches groundFloor's: offer the URL the app
|
||||
// itself loaded (Vite serves an edited file as `…?t=<hmr stamp>`, and
|
||||
// the plain path would hand back a second, unrelated instance whose
|
||||
// module state the app never touches), then prove identity by requiring
|
||||
// its default export to BE the live layer object.
|
||||
let ns = null;
|
||||
const urls = [...new Set([
|
||||
...performance.getEntriesByType('resource').map((e) => e.name)
|
||||
.filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(),
|
||||
'/src/data/flights.js',
|
||||
])];
|
||||
for (const url of urls) {
|
||||
let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; }
|
||||
if (mod?.default === fl && typeof mod._driveFleetModelHandoffForTest === 'function') {
|
||||
ns = mod;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ns) return { skipped: `the app's own flights module was not reachable (tried ${urls.length})` };
|
||||
// Test the registered instance directly, including catalogs constructed
|
||||
// with their own sources. Never import a second compatibility instance.
|
||||
const ns = fl.testing;
|
||||
if (typeof ns?._driveFleetModelHandoffForTest !== 'function')
|
||||
return { error: "the registered flights instance has no handoff test seam" };
|
||||
// Use a scenario-owned contact so its groundSnap entry is provably cold;
|
||||
// earlier display-floor cases intentionally exercise aaa097's cache.
|
||||
const holdIcao = 'aaa098';
|
||||
@@ -3972,6 +4061,7 @@ async function main() {
|
||||
delete window.__dfPriorTilesLoadedDescriptor;
|
||||
delete window.__dfSkinSamples;
|
||||
delete window.__dfSkinM;
|
||||
delete window.__dfObserveTrackedExit;
|
||||
await fl.update(v);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user