mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge main into pr-594
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
714d3dd1bb
492 files changed
+88532
-7676
No files matched your search
@@ -35,9 +35,12 @@ const portableExport = (key) =>
|
||||
/^\.\/layers\/(?:flights|military|vessels)\/(?:records|ingestion)$/.test(
|
||||
key,
|
||||
) ||
|
||||
['./voice/action-schemas', './voice/session', './data/lifecycle'].includes(
|
||||
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) {
|
||||
|
||||
@@ -89,6 +89,10 @@ export async function checkPackageBoundaries(root) {
|
||||
const seen = new Set();
|
||||
await build({
|
||||
root,
|
||||
// Keep this gate out of a running dev server's dependency cache
|
||||
// (node_modules/.vite): a build there re-optimizes dependencies and the
|
||||
// dev server's workers then fail with 504 "Outdated Optimize Dep".
|
||||
cacheDir: path.join(root, 'node_modules/.vite-boundaries'),
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
publicDir: false,
|
||||
@@ -115,7 +119,12 @@ export async function checkPackageBoundaries(root) {
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
input,
|
||||
external: group.external,
|
||||
// A declared dependency also owns its package subpath exports
|
||||
// (for example `@jtarrio/webrtlsdr/rtlsdr.js`).
|
||||
external: (id) =>
|
||||
group.external.some(
|
||||
(name) => id === name || id.startsWith(`${name}/`),
|
||||
),
|
||||
// Unused imports must still obey ownership; tree shaking is not a boundary.
|
||||
treeshake: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
|
||||
+114
-1
@@ -31,6 +31,7 @@
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
"src/standalone/data.js",
|
||||
@@ -99,6 +100,8 @@
|
||||
"src/tooling/previewServing.test.mjs",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
"src/data/tapAddress.test.mjs",
|
||||
"src/data/tapAddress.js",
|
||||
"server/providers/local.js",
|
||||
"server/providers/military-installations.js",
|
||||
"server/providers/military-installations/cache.js",
|
||||
@@ -142,6 +145,8 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/standalone/placeSearch.js",
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/ui/imagerySplit.test.mjs",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/surfaceKeyboard.test.mjs",
|
||||
"src/ui/panelRails.js",
|
||||
@@ -181,6 +186,7 @@
|
||||
"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",
|
||||
@@ -333,6 +339,16 @@
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
"src/layers/earthquakes/ownership.test.mjs",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/perimeters/records.test.mjs",
|
||||
"src/layers/perimeters/source.test.mjs",
|
||||
"src/layers/perimeters/cards.test.mjs",
|
||||
"src/layers/perimeters/inciweb.test.mjs",
|
||||
"src/layers/perimeters/ownership.test.mjs",
|
||||
"src/data/firmsHeatmap.js",
|
||||
"src/data/fireAnchors.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
@@ -382,6 +398,10 @@
|
||||
"src/layers/launches/model.js",
|
||||
"src/layers/launches/selection.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/data/localGeojson.analyst.test.mjs",
|
||||
"src/data/satellites.analyst.test.mjs",
|
||||
"src/data/layerSnapshot.test.mjs",
|
||||
"src/data/satellitePass.test.mjs",
|
||||
"src/data/satellites.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
@@ -494,6 +514,7 @@
|
||||
"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",
|
||||
@@ -542,11 +563,13 @@
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/layers/submarineCables/bundledSource.test.mjs",
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/controller.test.mjs",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
@@ -594,6 +617,7 @@
|
||||
"src/app/layers/flights.js",
|
||||
"src/app/layers/radio.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/satellites.js",
|
||||
@@ -702,6 +726,7 @@
|
||||
"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",
|
||||
@@ -724,7 +749,95 @@
|
||||
"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/sources/overpassFeatures.test.mjs",
|
||||
"src/data/cctvHlsStream.test.mjs",
|
||||
"src/layers/cctv/videoPlayback.test.mjs",
|
||||
"src/ui/cctvVideo.test.mjs",
|
||||
"src/ui/styles/weather.css",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/clock.test.mjs",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/shellRendering.test.mjs",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredAlpha.test.mjs",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/infraredImage.test.mjs",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/weather.test.mjs",
|
||||
"src/layers/weather/rasterTiles.test.mjs",
|
||||
"src/layers/wind/rendering.test.mjs",
|
||||
"src/layers/weather/source.test.mjs",
|
||||
"src/data/weatherProxy.test.mjs",
|
||||
"server/providers/weather.js",
|
||||
"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/ui/weatherPanel.js",
|
||||
"src/ui/weatherPanel.test.mjs",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCards.test.mjs",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/railTimeline.test.mjs",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryOrder.test.mjs",
|
||||
"server/providers/cyclones.js",
|
||||
"server/providers/firePerimeters.js",
|
||||
"src/data/cyclonesProxy.test.mjs",
|
||||
"src/data/firePerimetersProxy.test.mjs",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/layers/cyclones/rendering.test.mjs",
|
||||
"src/layers/cyclones/labels.test.mjs",
|
||||
"src/layers/cyclones/index.test.mjs",
|
||||
"src/layers/cyclones/source.test.mjs",
|
||||
"src/data/aisStreamSentinels.test.mjs",
|
||||
"scripts/qa-weather-perf.mjs",
|
||||
"scripts/qa-weather-journey.mjs",
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/sdr/adsbDecoder.test.mjs",
|
||||
"src/sdr/controller.test.mjs",
|
||||
"src/sdr/gain.test.mjs",
|
||||
"src/sdr/spectrum.test.mjs",
|
||||
"src/sdr/usbDevices.test.mjs",
|
||||
"src/sources/adsbRecords.test.mjs",
|
||||
"src/layers/localAdsb/index.test.mjs",
|
||||
"src/ui/localSdrControls.test.mjs",
|
||||
"src/ui/localSdrMarkup.test.mjs",
|
||||
"src/ui/localSdrPresentation.test.mjs",
|
||||
"src/data/localReceiversProxy.test.mjs",
|
||||
"src/layers/localAdsb/feeds.test.mjs",
|
||||
"src/ui/railScrollers.test.mjs",
|
||||
"src/layers/localAdsb/motion.test.mjs",
|
||||
"src/data/modelEligibility.test.mjs",
|
||||
"src/layers/recentImagery/model.test.mjs",
|
||||
"src/layers/recentImagery/catalog.test.mjs",
|
||||
"src/maps/imageryHost.test.mjs",
|
||||
"src/layers/recentImagery/rendering.test.mjs",
|
||||
"src/layers/recentImagery/thumbnails.test.mjs",
|
||||
"src/layers/recentImagery/index.test.mjs",
|
||||
"src/ui/imageryBoxTool.test.mjs",
|
||||
"src/ui/recentImagery.test.mjs",
|
||||
"src/layers/recentImagery/testDoubles.mjs",
|
||||
"scripts/qa-recent-imagery.mjs"
|
||||
]
|
||||
+381
-16
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"viewer": {
|
||||
"exports": ["./application/viewer"],
|
||||
"modules": ["src/app/viewer.js"],
|
||||
"modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"vite-build": {
|
||||
@@ -161,6 +161,7 @@
|
||||
"server/providers/cctv/normalize.js",
|
||||
"server/providers/cctv/range.js",
|
||||
"server/providers/cctv/sources.js",
|
||||
"server/providers/cctv/stream.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/data/directionText.js",
|
||||
"server/providers/common/geo.js",
|
||||
@@ -256,6 +257,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"src/hudLayoutPolicy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
@@ -268,6 +270,10 @@
|
||||
"server/providers/openai/realtime.js",
|
||||
"server/providers/openai/tools.js",
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/data/feedState.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/actionSchemas.js",
|
||||
"server/providers/openai/toolDescriptions.js",
|
||||
@@ -322,6 +328,7 @@
|
||||
"exports": ["./ui/layout"],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
@@ -375,11 +382,18 @@
|
||||
"modules": [
|
||||
"src/ui/layers.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/loadingFeedback.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/keySetupCore.mjs",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -413,6 +427,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -449,6 +464,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -474,6 +490,10 @@
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/bloom.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
@@ -504,6 +524,9 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -511,6 +534,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -519,6 +543,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -538,6 +563,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -574,7 +605,16 @@
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.js",
|
||||
"src/worldFocus.js",
|
||||
"src/data/feedState.js"
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/ui/localSdrPresentation.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -602,7 +642,32 @@
|
||||
"src/ui/styles/command-dock-sliding.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/first-run.css",
|
||||
"src/ui/styles/provider-settings.css"
|
||||
"src/ui/styles/provider-settings.css",
|
||||
"src/ui/styles/weather.css",
|
||||
"src/ui/styles/recent-imagery.css"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"director": {
|
||||
"exports": ["./director"],
|
||||
"modules": [
|
||||
"src/director/playback.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/director/document.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -615,6 +680,8 @@
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/bloom.js",
|
||||
@@ -622,7 +689,36 @@
|
||||
"src/ui/scenes.js",
|
||||
"src/ui/sceneControls.js",
|
||||
"src/ui/scenePresentation.js",
|
||||
"src/contextModePolicy.js"
|
||||
"src/contextModePolicy.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/scenes/shotPresentation.js",
|
||||
"src/scenes/packs/registry.js",
|
||||
"src/scenes/packs/defaults.js",
|
||||
"src/scenes/packs/nepal.js",
|
||||
"src/director/document.js",
|
||||
"src/scenes/project.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/scenes/cameraMotion.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -640,6 +736,7 @@
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -671,13 +768,16 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/modelEligibility.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -706,7 +806,8 @@
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
"src/data/inputOwnership.js"
|
||||
"src/data/inputOwnership.js",
|
||||
"src/data/modelEligibility.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -768,6 +869,18 @@
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"recent-imagery": {
|
||||
"exports": ["./layers/recent-imagery"],
|
||||
"modules": [
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"fire-layer": {
|
||||
"exports": ["./layers/firms"],
|
||||
"modules": [
|
||||
@@ -796,6 +909,7 @@
|
||||
"modules": [
|
||||
"src/data/issPass.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/layers/satellites/catalog.js",
|
||||
"src/layers/satellites/controls.js",
|
||||
"src/layers/satellites/index.js",
|
||||
@@ -804,6 +918,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -817,6 +932,7 @@
|
||||
"launches-layer": {
|
||||
"exports": ["./layers/launches"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/index.js",
|
||||
@@ -863,6 +979,7 @@
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -965,6 +1082,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -975,7 +1093,7 @@
|
||||
"src/data/inputOwnership.js",
|
||||
"src/layers/cctv/sourcePolicy.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
"external": ["hls.js", "cesium"]
|
||||
},
|
||||
"alpr-cameras": {
|
||||
"exports": ["./layers/alpr"],
|
||||
@@ -1056,6 +1174,7 @@
|
||||
"modules": [
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
@@ -1076,13 +1195,15 @@
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/keySetupCore.mjs"
|
||||
"src/keySetupCore.mjs",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
@@ -1105,7 +1226,18 @@
|
||||
"src/voice/session.js",
|
||||
"src/voice/sessionCommands.js",
|
||||
"src/voice/realtimeSession.js",
|
||||
"src/voice/realtimeEvents.js"
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -1129,8 +1261,15 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/cyberSonarGpu.js",
|
||||
"src/scenes/nepalEvidencePack.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
@@ -1174,6 +1313,7 @@
|
||||
"src/app/surfaceServices.js",
|
||||
"src/app/tools.js",
|
||||
"src/app/viewer.js",
|
||||
"src/app/atmosphereCompat.js",
|
||||
"src/bloom.js",
|
||||
"src/camera.js",
|
||||
"src/cameraVerbs.js",
|
||||
@@ -1208,6 +1348,19 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/wind.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/layers/wind/source.js",
|
||||
"src/sources/windBody.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
@@ -1257,10 +1410,12 @@
|
||||
"src/data/regionalBrief.js",
|
||||
"src/data/regionalModel.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/satellites.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/data/spriteOrder.js",
|
||||
@@ -1343,6 +1498,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1448,6 +1604,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -1503,6 +1660,7 @@
|
||||
"src/maps/availability.js",
|
||||
"src/maps/catalog.js",
|
||||
"src/maps/controller.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
@@ -1518,6 +1676,8 @@
|
||||
"src/renderGovernor.js",
|
||||
"src/rightRailPolicy.js",
|
||||
"src/scenes/director.js",
|
||||
"src/scenes/playbackAdapter.js",
|
||||
"src/director/playback.js",
|
||||
"src/scenes/recipes.js",
|
||||
"src/scenes/scenePolicy.js",
|
||||
"src/scopeMask.js",
|
||||
@@ -1547,6 +1707,9 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
"src/ui/applicationShortcuts.js",
|
||||
"src/ui/cctv.js",
|
||||
@@ -1554,6 +1717,7 @@
|
||||
"src/ui/cctvCalibration.js",
|
||||
"src/ui/cctvControls.js",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvVideo.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"src/ui/clearLayersControl.js",
|
||||
"src/ui/cockpit.js",
|
||||
@@ -1562,6 +1726,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -1582,6 +1747,12 @@
|
||||
"src/ui/effects.js",
|
||||
"src/ui/frameRateMonitor.js",
|
||||
"src/ui/layerPanel.js",
|
||||
"src/ui/weatherPanel.js",
|
||||
"src/ui/railCards.js",
|
||||
"src/ui/railCardBlocks.js",
|
||||
"src/ui/chipGroup.js",
|
||||
"src/ui/rowList.js",
|
||||
"src/ui/railTimeline.js",
|
||||
"src/ui/layers.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/location.js",
|
||||
@@ -1630,6 +1801,7 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -1652,8 +1824,15 @@
|
||||
"src/voice/realtimeEvents.js",
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/data/feedState.js",
|
||||
"src/data/layerSnapshot.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/sources/reference.js",
|
||||
"src/search/nominatim.js",
|
||||
@@ -1682,6 +1861,7 @@
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/transitProxy.js",
|
||||
"src/data/gtfsRealtime.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/layers/transit/movement.js",
|
||||
"src/layers/transit/qaMetrics.js",
|
||||
@@ -1691,15 +1871,99 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
"src/ui/layerBindings.js",
|
||||
"src/ui/displayBindings.js",
|
||||
"src/ui/shellFacade.js",
|
||||
"src/voice/realtimePreferences.js",
|
||||
"src/voice/realtimeProtocol.js",
|
||||
"src/voice/realtimeDiagnostics.js",
|
||||
"src/voice/realtimeViewport.js",
|
||||
"src/voice/realtimeInputPolicy.js",
|
||||
"src/voice/realtimeInput.js",
|
||||
"src/voice/realtimeCost.js",
|
||||
"src/voice/realtimeFacade.js",
|
||||
"src/voice/realtimeRadio.js",
|
||||
"src/voice/realtimeTurns.js",
|
||||
"src/voice/realtimeConnection.js",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/source.js",
|
||||
"src/director/clock.js",
|
||||
"src/director/timeline.js",
|
||||
"src/scenes/shotPresentation.js",
|
||||
"src/scenes/packs/registry.js",
|
||||
"src/scenes/packs/defaults.js",
|
||||
"src/scenes/packs/nepal.js",
|
||||
"src/director/document.js",
|
||||
"src/scenes/project.js",
|
||||
"src/director/documentFields.js",
|
||||
"src/director/cameraDocument.js",
|
||||
"src/director/camera.js",
|
||||
"src/scenes/cameraMotion.js",
|
||||
"src/director/packs/source.js",
|
||||
"src/director/packs/session.js",
|
||||
"src/director/packs/geojson.js",
|
||||
"src/director/packs/manifest.js",
|
||||
"src/scenes/dataPacks/controller.js",
|
||||
"src/scenes/dataPacks/presentation.js",
|
||||
"src/scenes/dataPacks/geometry.js",
|
||||
"src/director/interactions/document.js",
|
||||
"src/director/interactions/session.js",
|
||||
"src/scenes/interactions.js",
|
||||
"src/director/sharing/bundle.js",
|
||||
"src/director/sharing/preview.js",
|
||||
"src/director/sharing/lifetime.js",
|
||||
"src/director/authoring.js",
|
||||
"src/scenes/sharing.js",
|
||||
"src/ui/sceneSharing.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/app/layers/localAdsb.js",
|
||||
"src/layers/localAdsb/index.js",
|
||||
"src/layers/localAdsb/policy.js",
|
||||
"src/layers/localAdsb/card.js",
|
||||
"src/layers/localAdsb/feeds.js",
|
||||
"src/layers/localAdsb/status.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
"src/sdr/spectrum.js",
|
||||
"src/sdr/gain.js",
|
||||
"src/sdr/usbDevices.js",
|
||||
"src/ui/localSdrControls.js",
|
||||
"src/ui/localSdrPresentation.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/ui/recentImagery.js",
|
||||
"src/ui/imageryBoxTool.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"@mapbox/vector-tile",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
"mgrs",
|
||||
"pbf",
|
||||
"satellite.js"
|
||||
"satellite.js",
|
||||
"@jtarrio/webrtlsdr"
|
||||
]
|
||||
},
|
||||
"application-catalog": {
|
||||
@@ -1710,7 +1974,10 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
@@ -1720,6 +1987,16 @@
|
||||
"src/annotations/resolver.js",
|
||||
"src/app/catalog.js",
|
||||
"src/app/constructCatalog.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/fields.js",
|
||||
"src/layers/wind/streamlines.js",
|
||||
"src/layers/wind/gpuRendering.js",
|
||||
"src/layers/wind/relief.js",
|
||||
"src/layers/wind/inspection.js",
|
||||
"src/layers/wind/presentation.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/weather/rasterTiles.js",
|
||||
"src/app/layers/aisLiveVessels.js",
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
@@ -1767,6 +2044,7 @@
|
||||
"src/data/infrastructure.js",
|
||||
"src/data/installationFeedback.js",
|
||||
"src/data/issPass.js",
|
||||
"src/data/satellitePass.js",
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/localGeojson.js",
|
||||
@@ -1784,6 +2062,7 @@
|
||||
"src/data/pickRegistry.js",
|
||||
"src/data/radioCountry.js",
|
||||
"src/data/renderAltitude.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/routePlausible.js",
|
||||
"src/data/satelliteClass.js",
|
||||
@@ -1858,6 +2137,7 @@
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
"src/layers/cctv/videoPlayback.js",
|
||||
"src/layers/cctv/rendering.js",
|
||||
"src/layers/cctv/selection.js",
|
||||
"src/layers/cctv/source.js",
|
||||
@@ -1963,6 +2243,7 @@
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
"src/layers/satellites/rendering.js",
|
||||
"src/layers/satellites/source.js",
|
||||
@@ -2026,6 +2307,7 @@
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
"src/layers/military/records.js",
|
||||
"src/layers/military/recordPolicy.js",
|
||||
"src/layers/military/snapshotRenderer.js",
|
||||
@@ -2049,6 +2331,12 @@
|
||||
"src/app/localGeojsonServices.js",
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/app/layers/perimeters.js",
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js",
|
||||
"src/layers/cctv/sourcePolicy.js",
|
||||
"src/search/nominatim.js",
|
||||
"src/nominatimGeocode.js",
|
||||
@@ -2085,15 +2373,56 @@
|
||||
"src/sources/overpassFeaturesRecords.js",
|
||||
"src/sources/overpassFeatures.js",
|
||||
"src/sources/featureSource.js",
|
||||
"src/sources/overpassRoads.js"
|
||||
"src/sources/overpassRoads.js",
|
||||
"src/layers/weather/index.js",
|
||||
"src/layers/weather/clock.js",
|
||||
"src/layers/weather/rendering.js",
|
||||
"src/layers/weather/shellRendering.js",
|
||||
"src/layers/weather/infraredAlpha.js",
|
||||
"src/layers/weather/infraredImage.js",
|
||||
"src/layers/weather/source.js",
|
||||
"src/layers/weather/imageryOrder.js",
|
||||
"src/layers/weather/imageryHost.js",
|
||||
"src/layers/cyclones/index.js",
|
||||
"src/layers/cyclones/rendering.js",
|
||||
"src/layers/cyclones/labels.js",
|
||||
"src/layers/cyclones/source.js",
|
||||
"src/app/layers/localAdsb.js",
|
||||
"src/layers/localAdsb/index.js",
|
||||
"src/layers/localAdsb/policy.js",
|
||||
"src/layers/localAdsb/card.js",
|
||||
"src/layers/localAdsb/feeds.js",
|
||||
"src/layers/localAdsb/status.js",
|
||||
"src/layers/localAdsb/feedNames.js",
|
||||
"src/layers/localAdsb/motion.js",
|
||||
"src/layers/localAdsb/models.js",
|
||||
"src/layers/localAdsb/enrichment.js",
|
||||
"src/sources/adsbRecords.js",
|
||||
"src/sdr/controller.js",
|
||||
"src/sdr/spectrum.js",
|
||||
"src/sdr/gain.js",
|
||||
"src/sdr/usbDevices.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionRenderDemand.js",
|
||||
"src/data/detectionPresentation.js",
|
||||
"src/data/modelEligibility.js",
|
||||
"src/app/layers/recentImagery.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
"cesium",
|
||||
"egm96-universal",
|
||||
"satellite.js",
|
||||
"@mapbox/vector-tile",
|
||||
"pbf",
|
||||
"mgrs"
|
||||
"mgrs",
|
||||
"@jtarrio/webrtlsdr"
|
||||
]
|
||||
},
|
||||
"application-operations": {
|
||||
@@ -2111,6 +2440,7 @@
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/scenePick.js",
|
||||
"src/keylessGeocoder.js",
|
||||
@@ -2156,7 +2486,11 @@
|
||||
},
|
||||
"ui-navigation": {
|
||||
"exports": ["./ui/navigation"],
|
||||
"modules": ["src/navigationPolicy.js", "src/ui/navigationController.js"],
|
||||
"modules": [
|
||||
"src/navigationPolicy.js",
|
||||
"src/ui/navigationController.js",
|
||||
"src/app/stateChannel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"ui-share-restoration": {
|
||||
@@ -2174,6 +2508,10 @@
|
||||
"ui-visual-settings": {
|
||||
"exports": ["./ui/visual-settings"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -2198,6 +2536,8 @@
|
||||
"ui-panel-chrome": {
|
||||
"exports": ["./ui/panel-chrome"],
|
||||
"modules": [
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/panelStackLayout.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/panelChrome.js",
|
||||
@@ -2288,7 +2628,7 @@
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
"exports": ["./voice/action-schemas"],
|
||||
"modules": ["src/voice/actionSchemas.js"],
|
||||
"modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"],
|
||||
"external": []
|
||||
},
|
||||
"voice-session": {
|
||||
@@ -2377,6 +2717,9 @@
|
||||
"src/sources/reference.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
"src/layers/earthquakes/records.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/submarineCables/bundledSource.js"
|
||||
],
|
||||
"external": []
|
||||
@@ -2447,6 +2790,7 @@
|
||||
"transit-standalone": {
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/app/sourceSlot.js",
|
||||
"src/celestialRing.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
@@ -2572,5 +2916,26 @@
|
||||
"src/sources/featureSource.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"perimeters": {
|
||||
"exports": ["./layers/perimeters"],
|
||||
"modules": [
|
||||
"src/layers/perimeters/index.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/perimeters/cards.js",
|
||||
"src/layers/perimeters/inciweb.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"perimeters-source": {
|
||||
"exports": ["./layers/perimeters/source"],
|
||||
"modules": [
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/layers/perimeters/source.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -1943,6 +1943,98 @@ try {
|
||||
}),
|
||||
);
|
||||
|
||||
// Cyber relocates its collapsed briefing header into the expanded utility
|
||||
// lane. Exercise actual height changes without reload and measure both cards.
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setHudVariant('cyber');
|
||||
manager.hud.setMode('on');
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', true);
|
||||
});
|
||||
const cyberRadioOrder = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const display = document
|
||||
.getElementById('cockpit-display-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const radio = document
|
||||
.getElementById('cockpit-radio-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const displayRect = display.getBoundingClientRect();
|
||||
const radioRect = radio.getBoundingClientRect();
|
||||
return {
|
||||
display: displayRect.toJSON(),
|
||||
radio: radioRect.toJSON(),
|
||||
displayExpanded: display.classList.contains('is-expanded'),
|
||||
radioExpanded: radio.classList.contains('is-expanded'),
|
||||
gap: radioRect.top - displayRect.bottom,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Cyber expanding Radio preserves the authored Display-then-Radio order',
|
||||
!cyberRadioOrder.displayExpanded
|
||||
&& cyberRadioOrder.radioExpanded
|
||||
&& cyberRadioOrder.display.height > 0
|
||||
&& cyberRadioOrder.radio.height > 0
|
||||
&& cyberRadioOrder.gap >= 7,
|
||||
JSON.stringify(cyberRadioOrder),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, 'cyber-radio-order.png') });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
});
|
||||
const cyberStyleStates = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const states = [];
|
||||
for (const style of ['normal', 'surveillance', 'thermal', 'retro']) {
|
||||
document.querySelector(`.style-btn[data-style="${style}"]`).click();
|
||||
states.push({
|
||||
requestedStyle: style,
|
||||
activeStyle: manager.activeStyle,
|
||||
hud: manager.hud.getVariant(),
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
selector: document.getElementById('hud-layout-select').value,
|
||||
savedHud: manager.getVisualState().hud.variant,
|
||||
});
|
||||
}
|
||||
return states;
|
||||
});
|
||||
check('Cockpit visual-style controls retain Cyber HUD, selector, shell and saved layout',
|
||||
cyberStyleStates.every(state => state.activeStyle === state.requestedStyle
|
||||
&& state.hud === 'cyber' && state.theme === 'cyber'
|
||||
&& state.selector === 'cyber' && state.savedHud === 'cyber'),
|
||||
JSON.stringify(cyberStyleStates));
|
||||
for (const height of [987, 720, 640, 900]) {
|
||||
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const geometry = await page.evaluate(() => {
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
const utility = document.getElementById('cockpit-utility-controls');
|
||||
const expanded = utility.querySelector('.cockpit-utility-control.is-expanded');
|
||||
const s = signal.getBoundingClientRect();
|
||||
const u = expanded.getBoundingClientRect();
|
||||
const toggle = document.getElementById('cockpit-signal-toggle');
|
||||
const t = toggle.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2);
|
||||
return { height: innerHeight, theme: document.documentElement.dataset.uiTheme,
|
||||
collapsed: signal.dataset.collapsed, signal: s.toJSON(), utility: u.toJSON(),
|
||||
reachable: Boolean(hit && toggle.contains(hit)),
|
||||
contained: u.bottom <= s.top - 7 && s.bottom < innerHeight,
|
||||
};
|
||||
});
|
||||
check(`Cyber collapsed Live Signals remains visible and reachable after resize to ${height}px`,
|
||||
geometry.theme === 'cyber' && geometry.collapsed === 'true'
|
||||
&& geometry.signal.width > 0 && geometry.signal.height > 0
|
||||
&& geometry.reachable && geometry.contained, JSON.stringify(geometry));
|
||||
if (height === 640 || height === 900) {
|
||||
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
|
||||
|
||||
const signalTransition = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const cockpit = manager.cockpitView;
|
||||
@@ -2171,7 +2263,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 +2296,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,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;
|
||||
+15
-11
@@ -23,13 +23,17 @@ try {
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.dataManager?._layerPanel,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: 60000,
|
||||
});
|
||||
const results = await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.dataManager;
|
||||
const container = manager._toggleContainer;
|
||||
const entry = document.querySelector(
|
||||
'script[type="module"][src*="/src/main.js"]',
|
||||
);
|
||||
const { application } = await import(entry.src);
|
||||
const { presentation } = application.getComponents().data;
|
||||
const container = presentation.panel._toggleContainer;
|
||||
const id = 'qa-panel-lifecycle';
|
||||
let listener = null;
|
||||
let enabled = 0;
|
||||
@@ -54,7 +58,7 @@ try {
|
||||
});
|
||||
const result = [];
|
||||
try {
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
const row = () => container.querySelector(`[data-layer-id="${id}"]`);
|
||||
const first = row().querySelector('.data-toggle-btn');
|
||||
result.push([
|
||||
@@ -66,7 +70,7 @@ try {
|
||||
'row subscription is installed',
|
||||
typeof listener === 'function',
|
||||
]);
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
first.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
@@ -97,7 +101,7 @@ try {
|
||||
]);
|
||||
await manager.setEnabled(id, true);
|
||||
const final = row().querySelector('.data-toggle-btn');
|
||||
manager._layerPanel.destroy();
|
||||
presentation._panel.destroy();
|
||||
final.click();
|
||||
await Promise.resolve();
|
||||
result.push([
|
||||
@@ -109,10 +113,10 @@ try {
|
||||
manager.isEnabled(id),
|
||||
]);
|
||||
} finally {
|
||||
manager._layerPanel?.destroy();
|
||||
manager._layerPanel = null;
|
||||
presentation._panel?.destroy();
|
||||
presentation._panel = null;
|
||||
await window.__gevQaUnregisterLayer(manager, id);
|
||||
manager.buildTogglePanel(container);
|
||||
presentation.mount(container);
|
||||
}
|
||||
result.push([
|
||||
'ordinary layer rows return after reassembly',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -374,7 +374,7 @@ async function main() {
|
||||
enabled: true,
|
||||
uncertain: true,
|
||||
});
|
||||
gev.dataManager._refreshTogglePanel();
|
||||
gev.dataManager.refreshLayerStats();
|
||||
const staticResult = module.setTuningStatic(true);
|
||||
const filterBefore = module.getUIState().filter;
|
||||
const filterControl = document.getElementById('radio-filter');
|
||||
@@ -447,7 +447,7 @@ async function main() {
|
||||
enabled: true,
|
||||
uncertain: false,
|
||||
});
|
||||
gev.dataManager._refreshTogglePanel();
|
||||
gev.dataManager.refreshLayerStats();
|
||||
module.cancelTuning();
|
||||
camera.flyTo = originalFlyTo;
|
||||
return result;
|
||||
@@ -4048,7 +4048,13 @@ async function main() {
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
|
||||
const expectedEntryId = `selected:${selected.id}`;
|
||||
let painted = null;
|
||||
for (let attempt = 0; attempt < 20 && !painted?.rect; attempt += 1) {
|
||||
// Radio's horizon pass reveals the selected entity on its own timer
|
||||
// after the camera jump, so a stationary view waits for both to settle.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 20 && !(painted?.rect && selectedEntity?.show === true);
|
||||
attempt += 1
|
||||
) {
|
||||
painted = await new Promise((resolve) => {
|
||||
let removePostRender = null;
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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') });
|
||||
|
||||
+107
-37
@@ -367,7 +367,7 @@ export async function sampleTransitPixels(page, palette) {
|
||||
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 = Math.floor(canvas.height - 1 - cy * 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]);
|
||||
@@ -399,46 +399,117 @@ export async function sampleTransitPixels(page, palette) {
|
||||
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 (let dx = -1; dx <= 1; dx++)
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
let u = (s.x + dx) / width,
|
||||
v = 1 - (s.y + dy) / 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;
|
||||
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);
|
||||
}
|
||||
@@ -451,9 +522,8 @@ export async function sampleTransitPixels(page, palette) {
|
||||
continue;
|
||||
}
|
||||
let centre = 0;
|
||||
for (let dx = -1; dx <= 1; dx += 1)
|
||||
for (let dy = -1; dy <= 1; dy += 1)
|
||||
centre += readLuma(s.x + dx, s.y + dy) / 9;
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
|
||||
|
||||
// Not registered or active: camera discovery and live polls never own this feed.
|
||||
/** CapMetro metadata under a private QA identity for selected-trail pixels. */
|
||||
export const TRAIL_VISIBILITY_FEED = Object.freeze({
|
||||
...getRegisteredTransitFeed('capmetro-austin'),
|
||||
id: 'qa-trail-capmetro',
|
||||
});
|
||||
|
||||
import { reportTransitVisibility } from './qa-transit-browser.mjs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
@@ -5,6 +14,7 @@ import {
|
||||
reduceMotion,
|
||||
reduceBackground,
|
||||
reduceSensorContrast,
|
||||
reduceTrailPixels,
|
||||
} from '../src/layers/transit/qaMetrics.js';
|
||||
|
||||
/** CDP includes objects collected during the sample, not just surviving objects. */
|
||||
@@ -581,3 +591,459 @@ export async function runBostonLive({
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
/** Google 3D proof: paired framebuffer reads reject coincidental road colours. */
|
||||
export async function runTrailVisibility({
|
||||
page,
|
||||
check,
|
||||
wait,
|
||||
shots,
|
||||
tag,
|
||||
selectStyle,
|
||||
altitude = 600,
|
||||
pitch = 45,
|
||||
}) {
|
||||
const name = 'Google 3D selected trail visible at 6 of 8 projected samples';
|
||||
await selectStyle(page, 'normal');
|
||||
await page.evaluate(
|
||||
async ({ altitude, pitch }) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('transit', true, { source: 'qa' });
|
||||
if (app.mapStackController.isStackAvailable('photoreal'))
|
||||
await app.mapStackController.setStack('photoreal');
|
||||
const camera = app.viewer.camera,
|
||||
C = camera.positionCartographic.constructor;
|
||||
camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
30.26725 - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
},
|
||||
{ altitude, pitch },
|
||||
);
|
||||
await wait(8000);
|
||||
const setup = await page.evaluate(
|
||||
async ({ altitude, pitch, feed }) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const google =
|
||||
!!app.tileset &&
|
||||
app.tileset.show !== false &&
|
||||
app.mapStackController.getActiveId() === 'photoreal';
|
||||
if (!google)
|
||||
return { google: false, reason: 'visible Google 3D tileset required' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const centreLat = 30.267 + 90 / 111320;
|
||||
const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat));
|
||||
if (Number.isFinite(floor))
|
||||
scene.camera.setView({
|
||||
destination: C.toCartesian(
|
||||
C.fromDegrees(
|
||||
-97.7431,
|
||||
centreLat - altitude / Math.tan((pitch * Math.PI) / 180) / 111320,
|
||||
floor + altitude,
|
||||
),
|
||||
),
|
||||
orientation: { heading: 0, pitch: (-pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
parts.selection.clearSelection();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
state._activeFeeds.clear();
|
||||
for (const key of state._vehicles.keys())
|
||||
parts.ingestion.removeVehicle(key);
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
const { seek, setRate } = await import('/src/data/contactPlayback.js');
|
||||
const now = Date.now();
|
||||
// Nine straight street fixes, 30 m apart. Use the production surface path.
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const timestamp = (now - 120000 + i * 15000) / 1000;
|
||||
parts.ingestion.applySnapshot(
|
||||
feed,
|
||||
{
|
||||
fetchedAt: timestamp * 1000,
|
||||
vehicles: [
|
||||
{
|
||||
id: 'trail-visible',
|
||||
routeId: '1',
|
||||
tripId: 'trail-visible',
|
||||
lat: 30.267 + (i * 30) / 111320,
|
||||
lon: -97.7431,
|
||||
timestamp,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stale: false },
|
||||
);
|
||||
}
|
||||
const entry = state._vehicles.get(`${feed.id}:trail-visible`);
|
||||
entry.qaFixture = true;
|
||||
const clocks = { wallNowMs: now, monoNowMs: performance.now() };
|
||||
seek(entry.track, now - 45000, clocks);
|
||||
setRate(entry.track, 1, clocks);
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.height.anchorFloors();
|
||||
parts.rendering.refreshVisibility();
|
||||
parts.rendering.maintainPresentation();
|
||||
parts.selection.selectVehicle(entry.key);
|
||||
return {
|
||||
google,
|
||||
key: entry.key,
|
||||
fixes: entry.track.count,
|
||||
startDisplayT: now - 45000,
|
||||
startLat: entry.sample.lat,
|
||||
};
|
||||
},
|
||||
{ altitude, pitch, feed: TRAIL_VISIBILITY_FEED },
|
||||
);
|
||||
if (!setup.google) {
|
||||
check(name, false, JSON.stringify(setup), { unexercised: true });
|
||||
return;
|
||||
}
|
||||
const cost = await page.evaluate(async (key) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const parts = layer._transitPartsForTest(),
|
||||
entry = layer._transitStateForTest()._vehicles.get(key);
|
||||
const gl = scene.context._gl,
|
||||
originals = new Map(),
|
||||
wrapped = new Map();
|
||||
let owner = null,
|
||||
headBytes = 0,
|
||||
bodyBytes = 0,
|
||||
headUploadMax = 0,
|
||||
bodyUploadMax = 0,
|
||||
frames = 0;
|
||||
const headCpu = [];
|
||||
const wrap = (primitive, kind) => {
|
||||
if (!primitive || wrapped.has(primitive)) return;
|
||||
const update = primitive.update;
|
||||
wrapped.set(primitive, update);
|
||||
primitive.update = function (frameState) {
|
||||
const before = performance.now();
|
||||
owner = kind;
|
||||
try {
|
||||
return update.call(this, frameState);
|
||||
} finally {
|
||||
owner = null;
|
||||
if (kind === 'head') headCpu.push(performance.now() - before);
|
||||
}
|
||||
};
|
||||
};
|
||||
for (const name of ['bufferData', 'bufferSubData']) {
|
||||
const original = gl[name];
|
||||
originals.set(name, original);
|
||||
gl[name] = function (...args) {
|
||||
if (owner) {
|
||||
const value = args[name === 'bufferData' ? 1 : 2],
|
||||
offset = args[3] || 0,
|
||||
length = args[4];
|
||||
const bytes =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: value
|
||||
? length !== undefined
|
||||
? length * (value.BYTES_PER_ELEMENT || 1)
|
||||
: value.byteLength - offset * (value.BYTES_PER_ELEMENT || 1)
|
||||
: 0;
|
||||
if (owner === 'head') headBytes += bytes;
|
||||
else bodyBytes += bytes;
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
const pre = scene.preRender.addEventListener(() => {
|
||||
headBytes = bodyBytes = 0;
|
||||
const trail = parts.trails.diagnostics();
|
||||
wrap(trail?.body, 'body');
|
||||
wrap(trail?.headPrimitive || trail?.head?._polylineCollection, 'head');
|
||||
});
|
||||
const post = scene.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
headUploadMax = Math.max(headUploadMax, headBytes);
|
||||
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes);
|
||||
});
|
||||
try {
|
||||
// Include a full history upload, even if the first selection already rendered.
|
||||
entry.pathRevision = -1;
|
||||
parts.trails.prepareEntry(entry);
|
||||
scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20000));
|
||||
headCpu.sort((a, b) => a - b);
|
||||
return {
|
||||
frames,
|
||||
headUploadMax,
|
||||
bodyUploadMax,
|
||||
headCpuP95Ms: headCpu[Math.floor(headCpu.length * 0.95)] ?? null,
|
||||
headCpuMaxMs: headCpu.at(-1) ?? null,
|
||||
};
|
||||
} finally {
|
||||
pre();
|
||||
post();
|
||||
for (const [name, original] of originals) gl[name] = original;
|
||||
for (const [primitive, update] of wrapped)
|
||||
if (!primitive.isDestroyed()) primitive.update = update;
|
||||
}
|
||||
}, setup.key);
|
||||
const result = await page.evaluate(async (setup) => {
|
||||
const app = window.__godsEyeView,
|
||||
scene = app.viewer.scene;
|
||||
const layer = app.dataManager.layers.get('transit').module;
|
||||
const state = layer._transitStateForTest(),
|
||||
parts = layer._transitPartsForTest();
|
||||
const entry = state._vehicles.get(setup.key);
|
||||
const trail = parts.trails.diagnostics();
|
||||
if (!entry || !trail)
|
||||
return { ...setup, present: 0, reason: 'selection disappeared' };
|
||||
const { setRate } = await import('/src/data/contactPlayback.js');
|
||||
setRate(entry.track, 0, {
|
||||
wallNowMs: Date.now(),
|
||||
monoNowMs: performance.now(),
|
||||
});
|
||||
parts.rendering.sampleIdle(entry);
|
||||
parts.rendering.schedulePlayback(entry);
|
||||
parts.rendering.syncRenderHold();
|
||||
const ready = () =>
|
||||
app.tileset?.show !== false &&
|
||||
app.tileset?.tilesLoaded === true &&
|
||||
(scene.globe?.show === false || scene.globe?.tilesLoaded === true) &&
|
||||
trail.body?.ready === true &&
|
||||
(!trail.headPrimitive || trail.headPrimitive.ready === true);
|
||||
// Require sustained readiness after freezing playback, before projecting
|
||||
// onto the refined surface. Repeated captures still check pixel stability.
|
||||
const tilesReady = await new Promise((resolve) => {
|
||||
const started = performance.now();
|
||||
let readySince = null;
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
readySince = ready() ? (readySince ?? now) : null;
|
||||
scene.requestRender();
|
||||
if (
|
||||
(readySince !== null && now - readySince >= 500) ||
|
||||
now - started >= 30000
|
||||
) {
|
||||
clearInterval(timer);
|
||||
resolve(readySince !== null && now - readySince >= 500);
|
||||
}
|
||||
}, 80);
|
||||
});
|
||||
if (!tilesReady)
|
||||
return {
|
||||
...setup,
|
||||
present: 0,
|
||||
reason: 'tiles or trail geometry did not become ready',
|
||||
};
|
||||
const canvas = scene.canvas,
|
||||
gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
const points = entry.trailSegments
|
||||
.filter((s) => s.toT <= entry.sample.displayT)
|
||||
.flatMap((s) => s.positions);
|
||||
if (trail.head?.show) points.push(...trail.head.positions);
|
||||
if (points.length < 2)
|
||||
return { ...setup, present: 0, reason: 'no completed path' };
|
||||
const C = scene.camera.positionCartographic.constructor;
|
||||
const V = entry.marker.position.constructor;
|
||||
const lengths = [0];
|
||||
for (let i = 1; i < points.length; i++)
|
||||
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
|
||||
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 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);
|
||||
const height = scene.sampleHeight(
|
||||
cart,
|
||||
[entry.marker, trail.body, trail.headPrimitive].filter(Boolean),
|
||||
);
|
||||
if (Number.isFinite(height)) cart.height = height;
|
||||
const surface = C.toCartesian(cart);
|
||||
const q = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.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,
|
||||
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
|
||||
};
|
||||
});
|
||||
const marker = entry.marker.position;
|
||||
const markerClip = mul(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]),
|
||||
);
|
||||
const sprite = {
|
||||
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
|
||||
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
|
||||
// Include the padded, rotated sprite and its selection bracket.
|
||||
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
|
||||
};
|
||||
const read = () =>
|
||||
samples.map(({ x, y, inFront }) => {
|
||||
const r = Math.ceil(3 * scale),
|
||||
px = Math.round(x * scale),
|
||||
py = Math.round(canvas.height - 1 - y * scale);
|
||||
if (
|
||||
!inFront ||
|
||||
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 data;
|
||||
});
|
||||
const frame = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('Trail framebuffer did not render within 5 s'));
|
||||
}, 5000);
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve(read());
|
||||
});
|
||||
scene.requestRender();
|
||||
});
|
||||
const bodyShow = trail.body?.show,
|
||||
headShow = trail.head?.show,
|
||||
headPrimitiveShow = trail.headPrimitive?.show,
|
||||
backingShow = trail.backing?.show;
|
||||
let showTrail = false;
|
||||
const applyVisibility = () => {
|
||||
if (trail.body) trail.body.show = showTrail && bodyShow;
|
||||
if (trail.headPrimitive)
|
||||
trail.headPrimitive.show = showTrail && headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = showTrail && headShow;
|
||||
if (trail.backing) trail.backing.show = showTrail && backingShow;
|
||||
};
|
||||
// Override the layer's frame update in both states; selection stays fixed.
|
||||
const removeVisibility = scene.preRender.addEventListener(applyVisibility);
|
||||
const captures = [];
|
||||
let captureReady = true;
|
||||
try {
|
||||
for (const visible of [false, true, false, true]) {
|
||||
showTrail = visible;
|
||||
// Let post-processing settle before each measured framebuffer.
|
||||
for (let i = 0; i < 3; i++) await frame();
|
||||
captures.push(await frame());
|
||||
const current = parts.trails.diagnostics();
|
||||
captureReady &&=
|
||||
ready() &&
|
||||
current?.body === trail.body &&
|
||||
current?.headPrimitive === trail.headPrimitive;
|
||||
}
|
||||
} finally {
|
||||
removeVisibility();
|
||||
if (trail.body) trail.body.show = bodyShow;
|
||||
if (trail.headPrimitive) trail.headPrimitive.show = headPrimitiveShow;
|
||||
if (trail.head) trail.head.show = headShow;
|
||||
if (trail.backing) trail.backing.show = backingShow;
|
||||
await frame();
|
||||
}
|
||||
const [off, on, offAgain, onAgain] = captures.map((patches) =>
|
||||
patches.map((p) => (p ? Array.from(p) : null)),
|
||||
);
|
||||
return {
|
||||
...setup,
|
||||
fixes: entry.track.count,
|
||||
selected: state._selectedKey === setup.key,
|
||||
pathMetres: lengths.at(-1),
|
||||
samples,
|
||||
on,
|
||||
off,
|
||||
onAgain,
|
||||
offAgain,
|
||||
sprite,
|
||||
tilesReady: captureReady,
|
||||
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
|
||||
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
|
||||
body: trail.body?.constructor.name,
|
||||
headStrategy: trail.headStrategy || 'PolylineCollection',
|
||||
headRebuilds: trail.headRebuilds ?? null,
|
||||
};
|
||||
}, setup);
|
||||
const pixels = reduceTrailPixels(result.on, result.off, result);
|
||||
result.cost = cost;
|
||||
result.altitude = altitude;
|
||||
result.pitch = pitch;
|
||||
result.present = pixels.present;
|
||||
result.total = pixels.total;
|
||||
result.samples = result.samples?.map((s, i) => ({
|
||||
...s,
|
||||
hit: pixels.hits[i],
|
||||
stable: pixels.stable[i],
|
||||
eligible: pixels.eligible[i],
|
||||
}));
|
||||
await page.screenshot({
|
||||
path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`,
|
||||
type: 'jpeg',
|
||||
quality: 95,
|
||||
});
|
||||
await writeFile(
|
||||
`${shots}/${tag}-${altitude}m-trail-visible.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key];
|
||||
check(
|
||||
name,
|
||||
result.google &&
|
||||
result.selected &&
|
||||
result.fixes >= 5 &&
|
||||
result.pathMetres > 15 &&
|
||||
result.elapsedDisplayMs >= 19000 &&
|
||||
result.movedMetres >= 5 &&
|
||||
pixels.pass,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
check(
|
||||
'selected trail stays within 2 MiB rebuild and 2 KiB/frame head uploads',
|
||||
cost.frames > 30 &&
|
||||
cost.bodyUploadMax > 0 &&
|
||||
cost.bodyUploadMax <= 2 * 1024 * 1024 &&
|
||||
cost.headUploadMax > 0 &&
|
||||
cost.headUploadMax <= 2048,
|
||||
JSON.stringify({ altitude, ...cost }),
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('transit')
|
||||
.module._transitPartsForTest()
|
||||
.selection.clearSelection(),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
|
||||
import {
|
||||
boundPageEvaluations,
|
||||
reportTransitVisibility,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
runFleetBudgets,
|
||||
runBostonMatrix,
|
||||
runBostonLive,
|
||||
runTrailVisibility,
|
||||
} from './qa-transit-scenes.mjs';
|
||||
/**
|
||||
* Browser proof for the Transit layer against a running app.
|
||||
@@ -78,6 +80,18 @@ const check = (
|
||||
failures += 1;
|
||||
} else if (!passed) failures += 1;
|
||||
};
|
||||
// A focused deterministic regression can run independently of live-feed QA.
|
||||
if (process.argv.includes('--heading-only')) {
|
||||
try {
|
||||
await runTransitHeadingRegression(BASE, check);
|
||||
} catch (error) {
|
||||
check('heading regression completed', false, error.stack || error.message);
|
||||
}
|
||||
console.log(`HEADING: ${failures ? 'FAIL' : 'PASS'} (${failures} failed)`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
if (runs('heading')) await runTransitHeadingRegression(BASE, check);
|
||||
|
||||
const renderingSource = await readFile(
|
||||
new URL('../src/layers/transit/rendering.js', import.meta.url),
|
||||
'utf8',
|
||||
@@ -1774,6 +1788,13 @@ try {
|
||||
selectStyle,
|
||||
sampleRendered,
|
||||
};
|
||||
if (runs('trail-visible'))
|
||||
for (const [altitude, pitch] of [
|
||||
[300, 55],
|
||||
[600, 45],
|
||||
[900, 45],
|
||||
])
|
||||
await runTrailVisibility({ ...sceneQA, altitude, pitch });
|
||||
if (runs('budgets')) await runFleetBudgets(sceneQA);
|
||||
if (runs('matrix')) await runBostonMatrix(sceneQA);
|
||||
if (runs('boston-live')) await runBostonLive(sceneQA);
|
||||
|
||||
@@ -625,12 +625,27 @@ async function runBehaviorLayer() {
|
||||
|
||||
// (4b) analyst engine end-to-end: count flights over Texas (region ring
|
||||
// via NE-pack/admin machinery), then a follow-up over the same set.
|
||||
// A cold boundary lookup can exceed the resolver's budget; it answers
|
||||
// region-timeout and keeps loading, so asking again must succeed.
|
||||
let texasTimeouts = 0;
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 });
|
||||
while (r?.code === 'region-timeout' && texasTimeouts < 8) {
|
||||
texasTimeouts += 1;
|
||||
await settle(5000);
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, limit: 5 });
|
||||
}
|
||||
report(r?.ok === true && Number.isFinite(r?.count) && r.count > 0 && String(r?.coverage?.scope || '').includes('Texas'),
|
||||
'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} err=${r?.error || ''}`);
|
||||
'behavior: analyst counts flights over Texas', `count=${r?.count} scope=${r?.coverage?.scope} timeouts=${texasTimeouts} err=${r?.error || ''}`);
|
||||
r = await run('analyst_query', { followUp: true, filters: [{ field: 'onGround', op: 'eq', value: false }], sortBy: 'altitudeM', limit: 3 });
|
||||
report(r?.ok === true && r?.coverage?.followUp === true,
|
||||
'behavior: analyst follow-up re-filters the remembered set', `count=${r?.count} followUp=${r?.coverage?.followUp}`);
|
||||
// A marine region resolves from the bundled Natural Earth pack in the page,
|
||||
// without the slower geocode fallback.
|
||||
const gulfStarted = Date.now();
|
||||
r = await run('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Gulf of Mexico' }, limit: 5 });
|
||||
const gulfMs = Date.now() - gulfStarted;
|
||||
report(r?.ok === true && String(r?.coverage?.scope || '').includes('Gulf of Mexico') && gulfMs < 3000,
|
||||
'behavior: analyst resolves the Gulf of Mexico from the bundled pack', `scope=${r?.coverage?.scope} ms=${gulfMs} err=${r?.error || ''}`);
|
||||
|
||||
// (5) zoom_to_globe is ABSOLUTE full-earth (>12,000 km band)
|
||||
r = await run('zoom_to_globe', {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-weather-journey — drive the Weather layers the way a person would and
|
||||
* keep the evidence: numbered screenshots plus per-step layer diagnostics.
|
||||
*
|
||||
* Starts at the Earth view, opens Data Layers, switches Wind on from its real
|
||||
* row button, cycles the wind fields/models, switches radar, satellite clouds,
|
||||
* lightning and cyclones on, follows "View storm" and "View US radar", zooms
|
||||
* into a regional view, a city view and a street view, replays history, and
|
||||
* ends with real wheel/drag input. Every step records the enabled layers'
|
||||
* diagnostics (imagery tiles loaded, shown observation time, wind geometry)
|
||||
* and a 2 s `scene.postRender` count so a stalled renderer cannot pass as a
|
||||
* healthy screenshot.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-weather-journey.mjs [--url http://localhost:4173]
|
||||
* [--label google] [--headless] [--out qa-shots/weather-journey]
|
||||
*
|
||||
* Requires a running dev server. Screenshots and steps.json land in
|
||||
* <out>/<label>/.
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'journey');
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-journey'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const steps = [];
|
||||
let index = 0;
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 2 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
// Non-2xx responses, counted per path so a burst of throttled tiles reads
|
||||
// as one line instead of one console error per tile.
|
||||
const failures = new Map();
|
||||
page.on('response', (r) => {
|
||||
const status = r.status();
|
||||
if (status < 400) return;
|
||||
const key = `${status} ${new URL(r.url()).pathname}`;
|
||||
failures.set(key, (failures.get(key) || 0) + 1);
|
||||
});
|
||||
const reportFailures = () => {
|
||||
if (!failures.size) return;
|
||||
console.log(
|
||||
' [http failures] ' +
|
||||
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
|
||||
);
|
||||
failures.clear();
|
||||
};
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error')
|
||||
console.log(' [console.error]', m.text().slice(0, 200));
|
||||
});
|
||||
// The Data Layers panel remembers its collapsed state; start it open.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try {
|
||||
localStorage.setItem('godsEyeView.v6.panelCollapsed.data-panel', '0');
|
||||
} catch {
|
||||
/* storage may be unavailable */
|
||||
}
|
||||
});
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(12_000);
|
||||
|
||||
const diagnostics = () =>
|
||||
page.evaluate(
|
||||
({ WEATHER }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
const out = {
|
||||
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
|
||||
globeShown: gev.viewer.scene.globe.show,
|
||||
heightM: Math.round(gev.viewer.camera.positionCartographic.height),
|
||||
summaryHeight: Math.round(
|
||||
document.querySelector('.weather-summary')?.getBoundingClientRect()
|
||||
.height ?? 0,
|
||||
),
|
||||
};
|
||||
for (const id of ['wind', ...WEATHER, 'weather-cyclones']) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) continue;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
const s = entry.module?.getStats?.() || {};
|
||||
out[id] =
|
||||
id === 'wind'
|
||||
? {
|
||||
mode: d.renderMode,
|
||||
ready: d.gpu?.ready,
|
||||
paths: d.gpu?.pathCount,
|
||||
visibleCells: d.gpu?.visibleCells,
|
||||
heightFade: d.gpu?.heightFade,
|
||||
overlay: d.overlay,
|
||||
imagery: d.imageryActive,
|
||||
error: s.error,
|
||||
}
|
||||
: id === 'weather-cyclones'
|
||||
? { count: s.count, error: s.error }
|
||||
: {
|
||||
time: d.time,
|
||||
playing: d.playing,
|
||||
loading: d.loading,
|
||||
loadedTiles: d.loadedTiles,
|
||||
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
|
||||
error: d.error,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ WEATHER },
|
||||
);
|
||||
|
||||
const renders = (ms = 2000) =>
|
||||
page.evaluate(
|
||||
(ms) =>
|
||||
new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
let n = 0;
|
||||
const off = scene.postRender.addEventListener(() => {
|
||||
n += 1;
|
||||
});
|
||||
setTimeout(() => {
|
||||
off();
|
||||
resolve(n);
|
||||
}, ms);
|
||||
}),
|
||||
ms,
|
||||
);
|
||||
|
||||
const shot = async (name, note = '') => {
|
||||
index += 1;
|
||||
const file = `${String(index).padStart(2, '0')}-${name}.png`;
|
||||
const d = await diagnostics();
|
||||
const r = await renders(2000);
|
||||
await page.screenshot({ path: path.join(outDir, file) });
|
||||
reportFailures();
|
||||
steps.push({ index, name, note, file, renders2s: r, ...d });
|
||||
console.log(` ${file} renders/2s=${r} ${note} ${JSON.stringify(d)}`);
|
||||
};
|
||||
|
||||
const clickRow = async (id) => {
|
||||
const ok = await page.evaluate((id) => {
|
||||
const btn = document.querySelector(
|
||||
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||
);
|
||||
if (!btn) return false;
|
||||
btn.scrollIntoView({ block: 'center' });
|
||||
btn.click();
|
||||
return true;
|
||||
}, id);
|
||||
if (!ok) console.log(` !! no row button for ${id}`);
|
||||
};
|
||||
const clickChip = async (id, chip) => {
|
||||
const ok = await page.evaluate(
|
||||
({ id, chip }) => {
|
||||
const btn = document.querySelector(
|
||||
`[data-layer-id="${id}"] [data-chip-id="${chip}"]`,
|
||||
);
|
||||
if (!btn || btn.disabled) return false;
|
||||
btn.scrollIntoView({ block: 'center' });
|
||||
btn.click();
|
||||
return true;
|
||||
},
|
||||
{ id, chip },
|
||||
);
|
||||
if (!ok) console.log(` !! chip ${id}/${chip} missing or disabled`);
|
||||
return ok;
|
||||
};
|
||||
const waitReady = (ids, timeout = 90_000) =>
|
||||
page
|
||||
.waitForFunction(
|
||||
(ids) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
for (const id of ids) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) return false;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
if (id === 'wind') {
|
||||
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
|
||||
return false;
|
||||
} else if (id !== 'weather-cyclones') {
|
||||
if (d.loading || (!d.time && !d.error)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout, polling: 250 },
|
||||
ids,
|
||||
)
|
||||
.catch(() =>
|
||||
console.log(` !! ${ids.join(',')} not ready within ${timeout} ms`),
|
||||
);
|
||||
const fly = async (
|
||||
lon,
|
||||
lat,
|
||||
height,
|
||||
pitch = -90,
|
||||
heading = 0,
|
||||
duration = 2,
|
||||
) => {
|
||||
await page.evaluate(
|
||||
({ lon, lat, height, pitch, heading, duration }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const d2r = Math.PI / 180;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.flyTo({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: lon * d2r,
|
||||
latitude: lat * d2r,
|
||||
height,
|
||||
}),
|
||||
orientation: { heading: heading * d2r, pitch: pitch * d2r, roll: 0 },
|
||||
duration,
|
||||
});
|
||||
},
|
||||
{ lon, lat, height, pitch, heading, duration },
|
||||
);
|
||||
await sleep(duration * 1000 + 3000);
|
||||
};
|
||||
|
||||
console.log(`\nqa-weather-journey · ${label} · ${url}`);
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled)
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
document.querySelector('#data-panel')?.classList.remove('collapsed');
|
||||
});
|
||||
|
||||
// 1. Earth view, clean.
|
||||
await fly(-97.7431, 30.2672, 17_368_000);
|
||||
await shot('globe-clean', 'Earth view, no layers');
|
||||
|
||||
// 2. Wind from its row, then each field, then ECMWF.
|
||||
await clickRow('wind');
|
||||
await waitReady(['wind']);
|
||||
await sleep(1500);
|
||||
await shot('globe-wind-trails', 'Wind on (trails, no field)');
|
||||
await clickChip('wind', 'overlay-speed');
|
||||
await sleep(2500);
|
||||
await shot('globe-wind-speed', 'Wind speed field');
|
||||
await clickChip('wind', 'overlay-temperature');
|
||||
await sleep(2500);
|
||||
await waitReady(['wind']);
|
||||
await shot('globe-wind-temperature', 'Wind + 2 m temperature');
|
||||
await clickChip('wind', 'overlay-pressure');
|
||||
await sleep(2500);
|
||||
await waitReady(['wind']);
|
||||
await shot('globe-wind-pressure', 'Wind + MSL pressure');
|
||||
await clickChip('wind', 'model-ifs');
|
||||
await waitReady(['wind']);
|
||||
await sleep(2500);
|
||||
await shot('globe-wind-ecmwf-pressure', 'ECMWF IFS model');
|
||||
await clickChip('wind', 'model-gfs');
|
||||
await clickChip('wind', 'overlay-speed');
|
||||
await waitReady(['wind']);
|
||||
await sleep(1500);
|
||||
|
||||
// 3. Observed products.
|
||||
for (const id of WEATHER) await clickRow(id);
|
||||
await clickRow('weather-cyclones');
|
||||
await waitReady([...WEATHER]);
|
||||
await sleep(2500);
|
||||
await shot(
|
||||
'globe-all-weather',
|
||||
'Wind speed + radar + regional IR + lightning + cyclones',
|
||||
);
|
||||
await clickChip('weather-satellite', 'clouds');
|
||||
await waitReady(['weather-satellite']);
|
||||
await sleep(2500);
|
||||
await shot('globe-global-infrared', 'Satellite: global infrared mosaic');
|
||||
await clickChip('weather-satellite', 'clouds-regional');
|
||||
await waitReady(['weather-satellite']);
|
||||
|
||||
// 4. Follow a storm if there is one (advisories arrive a few seconds after enable).
|
||||
await page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
!!document.querySelector(
|
||||
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
|
||||
),
|
||||
{ timeout: 25_000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const storm = await page.evaluate(() => {
|
||||
const first = document.querySelector(
|
||||
'[data-layer-id="weather-cyclones"] .data-toggle-chip:not([data-chip-id="focus"]):not([data-chip-id="advisory"])',
|
||||
);
|
||||
if (!first) return null;
|
||||
first.click();
|
||||
return first.textContent.trim();
|
||||
});
|
||||
if (storm) {
|
||||
await sleep(1500);
|
||||
if (await clickChip('weather-cyclones', 'focus')) {
|
||||
await sleep(6000);
|
||||
await shot('storm-view', `View storm: ${storm}`);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.viewer.camera.zoomIn(400_000),
|
||||
);
|
||||
await sleep(4000);
|
||||
await shot('storm-closer', 'Zoomed toward the storm');
|
||||
}
|
||||
} else console.log(' (no active cyclone advisories to follow)');
|
||||
|
||||
// 5. US radar, then regional → city → street over the same spot.
|
||||
await clickChip('weather-radar', 'coverage');
|
||||
await sleep(6000);
|
||||
await shot('us-radar', 'View US radar');
|
||||
await fly(-94.6, 37.5, 600_000);
|
||||
await shot('regional-600km', 'Regional 600 km');
|
||||
await fly(-94.6, 37.5, 120_000);
|
||||
await shot('regional-120km', 'Regional 120 km');
|
||||
await fly(-97.7431, 30.2672, 12_000, -60, 20);
|
||||
await shot('city-12km', 'Austin 12 km oblique');
|
||||
await fly(-97.7431, 30.2672, 1_200, -45, 20);
|
||||
await shot('city-1200m', 'Austin 1.2 km oblique (the perf route pose)');
|
||||
await fly(-97.7431, 30.2672, 350, -30, 60);
|
||||
await shot('street-350m', 'Austin 350 m street level');
|
||||
|
||||
// 6. History playback at the city pose (panel behavior while frames swap).
|
||||
await clickChip('weather-radar', 'play');
|
||||
const heights = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await sleep(2200);
|
||||
heights.push(
|
||||
await page.evaluate(() =>
|
||||
Math.round(
|
||||
document.querySelector('.weather-summary')?.getBoundingClientRect()
|
||||
.height ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await shot(
|
||||
'city-history-playing',
|
||||
`History playing; summary heights ${heights.join('/')}`,
|
||||
);
|
||||
await clickChip('weather-radar', 'play');
|
||||
await clickChip('weather-radar', 'latest');
|
||||
|
||||
// 7. Real input: wheel-zoom out, drag, wheel-zoom in.
|
||||
await fly(-97.7431, 30.2672, 40_000, -55, 0, 1.5);
|
||||
const canvas = await page.$(
|
||||
'canvas.cesium-widget-canvas, .cesium-widget canvas',
|
||||
);
|
||||
const box = canvas
|
||||
? await canvas.boundingBox()
|
||||
: { x: 700, y: 460, width: 0, height: 0 };
|
||||
const cx = box.x + (box.width || 0) / 2;
|
||||
const cy = box.y + (box.height || 0) / 2;
|
||||
await page.mouse.move(cx, cy);
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel({ deltaY: 240 });
|
||||
await sleep(120);
|
||||
}
|
||||
await sleep(3000);
|
||||
await shot('wheel-out', 'Wheel zoom out ×12');
|
||||
await page.mouse.move(cx - 200, cy);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
await page.mouse.move(cx - 200 + i * 20, cy + i * 4, { steps: 2 });
|
||||
await sleep(30);
|
||||
}
|
||||
await page.mouse.up();
|
||||
await sleep(2500);
|
||||
await shot('drag-pan', 'Drag pan');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.wheel({ deltaY: -240 });
|
||||
await sleep(120);
|
||||
}
|
||||
await sleep(4000);
|
||||
await shot('wheel-in', 'Wheel zoom in ×10');
|
||||
|
||||
// 8. Back to Earth with everything on.
|
||||
await fly(-97.7431, 30.2672, 17_368_000, -90, 0, 2.5);
|
||||
await shot('globe-return', 'Return to Earth view');
|
||||
|
||||
writeFileSync(
|
||||
path.join(outDir, 'steps.json'),
|
||||
JSON.stringify({ label, url, steps }, null, 2),
|
||||
);
|
||||
console.log(`\nWrote ${steps.length} steps to ${outDir}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-weather-perf — matched-route throughput and idle-work measurement for the
|
||||
* Weather layers (Wind, Rain radar, Satellite clouds, Lightning density).
|
||||
*
|
||||
* This is a measurement harness, not a pass/fail gate. It reproduces one fixed
|
||||
* camera route per weather state so two builds (or two basemaps) can be
|
||||
* compared on the same numbers:
|
||||
*
|
||||
* park 10 km over Austin → 3 s flight to 1.2 km (heading 20°, pitch −45°)
|
||||
* → 6 s close idle → 8 s orbit at 1.8 km range → 3 s flight back to the
|
||||
* 17,368 km globe view → 6 s globe idle.
|
||||
*
|
||||
* Route windows hold render demand constant (`scene.requestRender()` on every
|
||||
* animation frame) so the idle-render governor cannot masquerade as low FPS.
|
||||
* FPS counts `scene.postRender` fires, and every window asserts the canvas
|
||||
* size did not change, because a stopped renderer with a free-running rAF
|
||||
* reads as a clean 60 fps otherwise.
|
||||
*
|
||||
* The separate idle test restores the normal governor, parks the camera at
|
||||
* the 1.2 km pose with Wind off, and counts scene redraws, DOM mutations
|
||||
* inside `.weather-summary`, and `.weather-summary` height changes over
|
||||
* 8-second windows for: weather off, history paused, history playing.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/qa-weather-perf.mjs [--url http://localhost:4173]
|
||||
* [--label google-baseline] [--states off,wind,...] [--repeat 1]
|
||||
* [--headless] [--no-route] [--no-idle] [--out qa-shots/weather-perf]
|
||||
*
|
||||
* States: off · history-paused · history-playing · wind · wind+history-paused
|
||||
* · wind+history-playing. "history" is radar + regional clouds + lightning
|
||||
* together; "wind" is animated GFS wind with the Speed field selected.
|
||||
* Requires a running dev server. Screenshots and results.json land in
|
||||
* <out>/<label>/.
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
// The first-launch chooser would otherwise sit over the globe; `?welcome=0`
|
||||
// is the app's own suppression flag (src/firstRunExperience.js).
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg(
|
||||
'--label',
|
||||
new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'),
|
||||
);
|
||||
const ALL_STATES = [
|
||||
'off',
|
||||
'history-paused',
|
||||
'history-playing',
|
||||
'wind',
|
||||
'wind+history-paused',
|
||||
'wind+history-playing',
|
||||
];
|
||||
const states = arg('--states', ALL_STATES.join(','))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const s of states)
|
||||
if (!ALL_STATES.includes(s)) {
|
||||
console.error(`unknown state "${s}"; choose from ${ALL_STATES.join(', ')}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const repeat = Math.max(1, Number(arg('--repeat', '1')) || 1);
|
||||
// `--scale 2` renders at 2× resolutionScale (4× the pixels) so a GPU that idles
|
||||
// at 60 fps natively still exposes per-frame cost differences.
|
||||
const scale = Number(arg('--scale', '1')) || 1;
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-perf'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
|
||||
const AUSTIN = { lon: -97.7431, lat: 30.2672 };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const chromePath =
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath: chromePath,
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--window-size=1200,960',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const results = [];
|
||||
let reportFailures = null;
|
||||
const record = (row) => {
|
||||
results.push(row);
|
||||
reportFailures?.();
|
||||
const {
|
||||
phase,
|
||||
state,
|
||||
run,
|
||||
window: win,
|
||||
fps,
|
||||
p95,
|
||||
longTasks,
|
||||
mutations,
|
||||
heightChanges,
|
||||
canvasStable,
|
||||
} = row;
|
||||
console.log(
|
||||
` ${phase.padEnd(5)} ${state.padEnd(22)} r${run} ${win.padEnd(12)} ` +
|
||||
`fps=${String(fps).padStart(5)} p95=${String(p95).padStart(6)}ms ` +
|
||||
`long=${String(longTasks).padStart(2)} dom=${String(mutations).padStart(4)} ` +
|
||||
`h∆=${String(heightChanges).padStart(2)}${canvasStable ? '' : ' CANVAS CHANGED'}`,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1200, height: 886, deviceScaleFactor: 2 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
// Non-2xx responses, counted per path so a burst of throttled tiles reads
|
||||
// as one line instead of one console error per tile.
|
||||
const failures = new Map();
|
||||
page.on('response', (r) => {
|
||||
const status = r.status();
|
||||
if (status < 400) return;
|
||||
const key = `${status} ${new URL(r.url()).pathname}`;
|
||||
failures.set(key, (failures.get(key) || 0) + 1);
|
||||
});
|
||||
reportFailures = () => {
|
||||
if (!failures.size) return;
|
||||
console.log(
|
||||
' [http failures] ' +
|
||||
[...failures].map(([k, n]) => `${k} ×${n}`).join(' · '),
|
||||
);
|
||||
failures.clear();
|
||||
};
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(15_000);
|
||||
|
||||
if (scale !== 1)
|
||||
await page.evaluate((scale) => {
|
||||
window.__godsEyeView.viewer.resolutionScale = scale;
|
||||
window.__godsEyeView.viewer.scene.requestRender();
|
||||
}, scale);
|
||||
const env = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
return {
|
||||
basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
|
||||
globeShown: scene.globe.show,
|
||||
canvas: `${scene.canvas.width}x${scene.canvas.height}`,
|
||||
dpr: devicePixelRatio,
|
||||
ua:
|
||||
navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0] || navigator.userAgent,
|
||||
msaa: scene.msaaSamples,
|
||||
resolutionScale: gev.viewer.resolutionScale,
|
||||
};
|
||||
});
|
||||
console.log(`\nqa-weather-perf · ${label}\n url=${url}`);
|
||||
console.log(` ${JSON.stringify(env)}`);
|
||||
|
||||
// Park deterministically, disable every layer, and instrument once.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
v.camera.cancelFlight();
|
||||
for (const [id, entry] of gev.dataManager.layers)
|
||||
if (entry.enabled) {
|
||||
try {
|
||||
await gev.dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
} catch {
|
||||
/* counted through the layer, not here */
|
||||
}
|
||||
}
|
||||
const P = (window.__gevWeatherPerf = {
|
||||
renders: 0,
|
||||
longTasks: 0,
|
||||
longTaskMs: 0,
|
||||
mutations: 0,
|
||||
});
|
||||
v.scene.postRender.addEventListener(() => {
|
||||
P.renders += 1;
|
||||
});
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
P.longTasks += 1;
|
||||
P.longTaskMs += entry.duration;
|
||||
}
|
||||
}).observe({ type: 'longtask', buffered: false });
|
||||
} catch {
|
||||
/* longtask unsupported → stays 0 */
|
||||
}
|
||||
const summary = document.querySelector('.weather-summary');
|
||||
if (summary)
|
||||
new MutationObserver((records) => {
|
||||
P.mutations += records.length;
|
||||
}).observe(summary, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
});
|
||||
|
||||
const pose = (which) =>
|
||||
page.evaluate(
|
||||
({ which, AUSTIN }) => {
|
||||
const C = window.Cesium || null;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.lookAtTransform?.(
|
||||
(C || v.camera.constructor).Matrix4?.IDENTITY ??
|
||||
v.scene.camera.transform,
|
||||
);
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
const destination = ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height:
|
||||
which === 'overhead'
|
||||
? 10_000
|
||||
: which === 'close'
|
||||
? 1_200
|
||||
: 17_368_000,
|
||||
});
|
||||
v.camera.setView({
|
||||
destination,
|
||||
orientation:
|
||||
which === 'close'
|
||||
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
|
||||
: { heading: 0, pitch: -90 * d2r, roll: 0 },
|
||||
});
|
||||
},
|
||||
{ which, AUSTIN },
|
||||
);
|
||||
|
||||
/** Start a 3 s flight without awaiting it; the caller measures alongside. */
|
||||
const fly = (which) =>
|
||||
page.evaluate(
|
||||
({ which, AUSTIN }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
v.camera.flyTo({
|
||||
destination: ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height: which === 'close' ? 1_200 : 17_368_000,
|
||||
}),
|
||||
orientation:
|
||||
which === 'close'
|
||||
? { heading: 20 * d2r, pitch: -45 * d2r, roll: 0 }
|
||||
: { heading: 0, pitch: -90 * d2r, roll: 0 },
|
||||
duration: 3,
|
||||
});
|
||||
},
|
||||
{ which, AUSTIN },
|
||||
);
|
||||
|
||||
/** Start an 8 s orbit at 1.8 km range around the Austin anchor (not awaited). */
|
||||
const orbit = () =>
|
||||
page.evaluate(
|
||||
({ AUSTIN }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const camera = v.camera;
|
||||
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
const center = ell.cartographicToCartesian({
|
||||
longitude: AUSTIN.lon * d2r,
|
||||
latitude: AUSTIN.lat * d2r,
|
||||
height: 0,
|
||||
});
|
||||
const HPR = camera.constructor?.HeadingPitchRange
|
||||
? camera.constructor.HeadingPitchRange
|
||||
: null;
|
||||
const t0 = performance.now();
|
||||
let heading = 20 * d2r;
|
||||
const step = (now) => {
|
||||
const elapsed = (now - t0) / 1000;
|
||||
heading = 20 * d2r + (elapsed / 8) * Math.PI * 2;
|
||||
const hpr = HPR
|
||||
? new HPR(heading, -45 * d2r, 1800)
|
||||
: { heading, pitch: -45 * d2r, range: 1800 };
|
||||
camera.lookAt(center, hpr);
|
||||
if (elapsed < 8) requestAnimationFrame(step);
|
||||
else camera.lookAtTransform(camera.transform.constructor.IDENTITY);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
{ AUSTIN },
|
||||
);
|
||||
|
||||
/** Measure one window; `hold` keeps render demand constant. */
|
||||
const measure = (ms, hold) =>
|
||||
page.evaluate(
|
||||
({ ms, hold }) =>
|
||||
new Promise((resolve) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const scene = gev.viewer.scene;
|
||||
const P = window.__gevWeatherPerf;
|
||||
const canvas = scene.canvas;
|
||||
const w0 = canvas.width;
|
||||
const h0 = canvas.height;
|
||||
const r0 = P.renders;
|
||||
const l0 = P.longTasks;
|
||||
const lm0 = P.longTaskMs;
|
||||
const m0 = P.mutations;
|
||||
const summary = document.querySelector('.weather-summary');
|
||||
const intervals = [];
|
||||
const heights = [];
|
||||
let lastH = -1;
|
||||
let rafs = 0;
|
||||
let last = performance.now();
|
||||
const t0 = last;
|
||||
const finish = (now) => {
|
||||
const seconds = (now - t0) / 1000;
|
||||
intervals.sort((a, b) => a - b);
|
||||
const q = (p) =>
|
||||
intervals[
|
||||
Math.min(intervals.length - 1, Math.floor(p * intervals.length))
|
||||
] ?? 0;
|
||||
const hs = heights.map(([, h]) => h);
|
||||
resolve({
|
||||
seconds: Number(seconds.toFixed(2)),
|
||||
renders: P.renders - r0,
|
||||
fps: Number(((P.renders - r0) / seconds).toFixed(1)),
|
||||
rafHz: Number((rafs / seconds).toFixed(1)),
|
||||
p50: Number(q(0.5).toFixed(1)),
|
||||
p95: Number(q(0.95).toFixed(1)),
|
||||
max: Number((intervals.at(-1) ?? 0).toFixed(1)),
|
||||
longTasks: P.longTasks - l0,
|
||||
longTaskMs: Math.round(P.longTaskMs - lm0),
|
||||
mutations: P.mutations - m0,
|
||||
heightChanges: Math.max(0, heights.length - 1),
|
||||
heightMin: hs.length ? Math.min(...hs) : null,
|
||||
heightMax: hs.length ? Math.max(...hs) : null,
|
||||
canvasStable: canvas.width === w0 && canvas.height === h0,
|
||||
canvas: `${w0}x${h0}`,
|
||||
});
|
||||
};
|
||||
const tick = (now) => {
|
||||
rafs += 1;
|
||||
intervals.push(now - last);
|
||||
last = now;
|
||||
if (hold) scene.requestRender();
|
||||
if (summary && !summary.hidden) {
|
||||
const h =
|
||||
Math.round(summary.getBoundingClientRect().height * 2) / 2;
|
||||
if (h !== lastH) {
|
||||
heights.push([Math.round(now - t0), h]);
|
||||
lastH = h;
|
||||
}
|
||||
}
|
||||
if (now - t0 < ms) requestAnimationFrame(tick);
|
||||
else finish(now);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
{ ms, hold },
|
||||
);
|
||||
|
||||
const diagnostics = () =>
|
||||
page.evaluate(
|
||||
({ WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const out = {};
|
||||
for (const id of ['wind', ...WEATHER]) {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry?.enabled) continue;
|
||||
const d = entry.module?.getDiagnostics?.() || {};
|
||||
out[id] =
|
||||
id === 'wind'
|
||||
? {
|
||||
mode: d.renderMode,
|
||||
ready: d.gpu?.ready,
|
||||
paths: d.gpu?.pathCount,
|
||||
vertices: d.gpu?.vertexCount,
|
||||
buildMs: d.gpu?.buildMs && Math.round(d.gpu.buildMs),
|
||||
cells: d.gpu?.cellCount,
|
||||
visibleCells: d.gpu?.visibleCells,
|
||||
visibleVertices: d.gpu?.visibleVertexCount,
|
||||
heightFade: d.gpu?.heightFade,
|
||||
}
|
||||
: {
|
||||
time: d.time,
|
||||
playing: d.playing,
|
||||
loading: d.loading,
|
||||
loadedTiles: d.loadedTiles,
|
||||
imageryCount: d.imageryCount,
|
||||
frameLoadMs: d.frameLoadMs && Math.round(d.frameLoadMs),
|
||||
error: d.error,
|
||||
suspended: d.suspended ?? d.globeHidden ?? null,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ WEATHER },
|
||||
);
|
||||
|
||||
/** Enable the layers a state names, wait for first imagery/geometry, then set playback. */
|
||||
const applyState = async (state) => {
|
||||
const wantWind = state.includes('wind');
|
||||
const wantHistory = state.includes('history');
|
||||
const wantPlaying = state.includes('playing');
|
||||
const started = Date.now();
|
||||
await page.evaluate(
|
||||
async ({ wantWind, wantHistory, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const set = async (id, on) => {
|
||||
const entry = dm.layers.get(id);
|
||||
if (!entry || Boolean(entry.enabled) === on) return;
|
||||
await dm.setEnabled(id, on, { origin: 'user' });
|
||||
};
|
||||
await set('wind', wantWind);
|
||||
if (wantWind)
|
||||
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
|
||||
for (const id of WEATHER) await set(id, wantHistory);
|
||||
},
|
||||
{ wantWind, wantHistory, WEATHER },
|
||||
);
|
||||
let ready = true;
|
||||
await page
|
||||
.waitForFunction(
|
||||
({ wantWind, wantHistory, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
if (wantWind) {
|
||||
const d = dm.layers.get('wind')?.module?.getDiagnostics?.();
|
||||
if (!d) return false;
|
||||
if (d.renderMode === 'gpu-streamlines' && !d.gpu?.ready)
|
||||
return false;
|
||||
}
|
||||
if (wantHistory)
|
||||
for (const id of WEATHER) {
|
||||
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
|
||||
if (!d || d.loading || (!d.time && !d.error)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 90_000, polling: 250 },
|
||||
{ wantWind, wantHistory, WEATHER },
|
||||
)
|
||||
.catch(() => {
|
||||
ready = false;
|
||||
});
|
||||
const readyMs = Date.now() - started;
|
||||
await page.evaluate(
|
||||
({ wantPlaying, WEATHER }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
for (const id of WEATHER) {
|
||||
const module = dm.layers.get(id)?.module;
|
||||
if (!module || !dm.layers.get(id)?.enabled) continue;
|
||||
const playing = Boolean(module.getDiagnostics?.().playing);
|
||||
if (playing !== wantPlaying)
|
||||
dm.setLayerParams(id, { play: true }, { origin: 'user' });
|
||||
}
|
||||
},
|
||||
{ wantPlaying, WEATHER },
|
||||
);
|
||||
await sleep(2_500);
|
||||
return { ready, readyMs, layers: await diagnostics() };
|
||||
};
|
||||
|
||||
const shot = (name) =>
|
||||
page.screenshot({ path: path.join(outDir, `${name}.png`) });
|
||||
|
||||
// ── Matched route ────────────────────────────────────────────────────────
|
||||
if (!flag('--no-route')) {
|
||||
console.log('\nRoute windows (continuous-render hold):');
|
||||
for (let run = 1; run <= repeat; run++) {
|
||||
const order = run % 2 === 1 ? states : [...states].reverse();
|
||||
for (const state of order) {
|
||||
await pose('overhead');
|
||||
const setup = await applyState(state);
|
||||
console.log(
|
||||
` setup ${state}: ready=${setup.ready} in ${setup.readyMs}ms ${JSON.stringify(setup.layers)}`,
|
||||
);
|
||||
const base = { phase: 'route', state, run, setup };
|
||||
await fly('close');
|
||||
record({ ...base, window: 'zoom-in', ...(await measure(6_000, true)) });
|
||||
record({
|
||||
...base,
|
||||
window: 'close-idle',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
await shot(`${state}-r${run}-close`);
|
||||
await orbit();
|
||||
record({ ...base, window: 'orbit', ...(await measure(8_000, true)) });
|
||||
await fly('globe');
|
||||
record({
|
||||
...base,
|
||||
window: 'zoom-out',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
record({
|
||||
...base,
|
||||
window: 'globe-idle',
|
||||
...(await measure(6_000, true)),
|
||||
});
|
||||
await shot(`${state}-r${run}-globe`);
|
||||
console.log(` after ${state}: ${JSON.stringify(await diagnostics())}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idle test (normal governor) ──────────────────────────────────────────
|
||||
if (!flag('--no-idle')) {
|
||||
console.log('\nIdle windows (normal governor, fixed 1.2 km pose):');
|
||||
for (const state of ['off', 'history-paused', 'history-playing', 'wind']) {
|
||||
await pose('close');
|
||||
const setup = await applyState(state);
|
||||
await sleep(4_000);
|
||||
for (let run = 1; run <= 2; run++)
|
||||
record({
|
||||
phase: 'idle',
|
||||
state,
|
||||
run,
|
||||
window: 'fixed-8s',
|
||||
setup,
|
||||
...(await measure(8_000, false)),
|
||||
});
|
||||
await shot(`idle-${state}`);
|
||||
}
|
||||
await applyState('off');
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify({ label, url, env, results }, null, 2),
|
||||
);
|
||||
const lines = [
|
||||
`# qa-weather-perf · ${label}`,
|
||||
'',
|
||||
`url: ${url} · ${env.basemap} · canvas ${env.canvas} @ DPR ${env.dpr} · ${env.ua}`,
|
||||
'',
|
||||
'| phase | state | run | window | fps | p50 ms | p95 ms | max ms | long tasks | long ms | dom mut | height ∆ | height min–max |',
|
||||
'|---|---|---|---|---|---|---|---|---|---|---|---|---|',
|
||||
...results.map(
|
||||
(r) =>
|
||||
`| ${r.phase} | ${r.state} | ${r.run} | ${r.window} | ${r.fps}${r.canvasStable ? '' : ' ⚠'} | ${r.p50} | ${r.p95} | ${r.max} | ${r.longTasks} | ${r.longTaskMs} | ${r.mutations} | ${r.heightChanges} | ${r.heightMin ?? '—'}–${r.heightMax ?? '—'} |`,
|
||||
),
|
||||
];
|
||||
writeFileSync(path.join(outDir, 'summary.md'), lines.join('\n') + '\n');
|
||||
console.log(`\nWrote ${path.join(outDir, 'results.json')} and summary.md`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Weather history-swap probe: does the scene keep drawing while a draped
|
||||
* weather frame is replaced? Runs against a live dev server, turns on one
|
||||
* observed product at the Earth view, steps Earlier and plays history while
|
||||
* screenshotting, then measures the black fraction inside the globe disc of
|
||||
* every capture. Writes <out>/<label>/summary.json.
|
||||
*
|
||||
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
|
||||
* [--layer weather-satellite] [--product clouds] [--headless]
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'swap');
|
||||
const layerId = arg('--layer', 'weather-satellite');
|
||||
const product = arg(
|
||||
'--product',
|
||||
layerId === 'weather-satellite' ? 'clouds' : null,
|
||||
);
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-swap'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
// Globe disc in CSS pixels for the 1400×920 viewport at the Earth view.
|
||||
const DISC = { cx: 700, cy: 460, r: 300 };
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
const frames = [];
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(12_000);
|
||||
await page.evaluate(
|
||||
async ({ layerId, product }) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
for (const [id, e] of dm.layers)
|
||||
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
|
||||
const v = gev.viewer;
|
||||
const d = Math.PI / 180;
|
||||
v.camera.setView({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: -80 * d,
|
||||
latitude: 25 * d,
|
||||
height: 17_368_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
|
||||
});
|
||||
await dm.setEnabled(layerId, true, { origin: 'user' });
|
||||
if (product) dm.setLayerParams(layerId, { product }, { origin: 'user' });
|
||||
},
|
||||
{ layerId, product },
|
||||
);
|
||||
await page
|
||||
.waitForFunction(
|
||||
({ layerId, product }) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(layerId)
|
||||
?.module?.getDiagnostics?.();
|
||||
return d && !d.loading && d.time && (!product || d.product === product);
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
{ layerId, product },
|
||||
)
|
||||
.catch(() => console.log(' !! first frame not ready'));
|
||||
await sleep(3000);
|
||||
const diag = () =>
|
||||
page.evaluate((layerId) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(layerId)
|
||||
.module.getDiagnostics();
|
||||
return {
|
||||
time: d.time,
|
||||
loading: d.loading,
|
||||
pending: d.pendingTiles,
|
||||
loaded: d.loadedTiles,
|
||||
imagery: d.imageryCount,
|
||||
mosaic: d.mosaic ?? null,
|
||||
};
|
||||
}, layerId);
|
||||
const capture = async (name) => {
|
||||
const file = `${name}.png`;
|
||||
await page.screenshot({ path: path.join(outDir, file) });
|
||||
frames.push({ file, ...(await diag()) });
|
||||
};
|
||||
await capture('00-latest');
|
||||
const step = (params) =>
|
||||
page.evaluate(
|
||||
({ layerId, params }) =>
|
||||
window.__godsEyeView.dataManager.setLayerParams(layerId, params, {
|
||||
origin: 'user',
|
||||
}),
|
||||
{ layerId, params },
|
||||
);
|
||||
await step({ step: -1 });
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await sleep(400);
|
||||
await capture(`earlier-${String(i).padStart(2, '0')}`);
|
||||
}
|
||||
await step({ play: true });
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
await sleep(500);
|
||||
if (i % 2 === 0) await capture(`play-${String(i).padStart(2, '0')}`);
|
||||
}
|
||||
await step({ play: true });
|
||||
|
||||
// Black fraction inside the globe disc, measured in a blank page.
|
||||
const meter = await browser.newPage();
|
||||
for (const frame of frames) {
|
||||
const b64 = readFileSync(path.join(outDir, frame.file)).toString('base64');
|
||||
const black = await meter.evaluate(
|
||||
async ({ b64, DISC }) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth;
|
||||
c.height = img.naturalHeight;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const s = img.naturalWidth / 1400;
|
||||
const d = ctx.getImageData(0, 0, c.width, c.height).data;
|
||||
const rr = (DISC.r * s) ** 2;
|
||||
let n = 0;
|
||||
let dark = 0;
|
||||
for (let y = 0; y < c.height; y += 2)
|
||||
for (let x = 0; x < c.width; x += 2) {
|
||||
if ((x - DISC.cx * s) ** 2 + (y - DISC.cy * s) ** 2 > rr) continue;
|
||||
n++;
|
||||
const i = (y * c.width + x) * 4;
|
||||
if (d[i] < 18 && d[i + 1] < 18 && d[i + 2] < 18) dark++;
|
||||
}
|
||||
return dark / n;
|
||||
},
|
||||
{ b64, DISC },
|
||||
);
|
||||
frame.black = Number(black.toFixed(4));
|
||||
console.log(
|
||||
`${frame.file}\t${(black * 100).toFixed(1)}%\t${frame.time}\tpending=${frame.pending}`,
|
||||
);
|
||||
}
|
||||
const blackFrames = frames.filter((f) => f.black > 0.15).length;
|
||||
const distinctTimes = new Set(frames.map((f) => f.time).filter(Boolean)).size;
|
||||
const summary = {
|
||||
url: rawUrl,
|
||||
label,
|
||||
layerId,
|
||||
product,
|
||||
frames: frames.length,
|
||||
blackFrames,
|
||||
maxBlack: Math.max(...frames.map((f) => f.black)),
|
||||
distinctTimes,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(outDir, 'summary.json'),
|
||||
JSON.stringify({ summary, frames }, null, 2),
|
||||
);
|
||||
console.log(
|
||||
`frames=${frames.length} black(>15%)=${blackFrames} max=${(summary.maxBlack * 100).toFixed(1)}% distinctTimes=${distinctTimes}`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Weather teardown and cross-layer regression probe.
|
||||
*
|
||||
* Against a running dev server: records a resource baseline, turns every
|
||||
* weather layer on and exercises it (history playback, a wind reading, a
|
||||
* storm selection), turns them all off, and asserts the scene is back to the
|
||||
* baseline (imagery layers on the globe and the tileset, primitives, ground
|
||||
* primitives, data sources, entities, camera/scene listeners, the weather
|
||||
* panel, the wind reading marker). Then it enables a set of non-weather
|
||||
* layers one at a time and samples the render rate, long tasks and console
|
||||
* errors for each, and repeats the same non-weather sequence in a control
|
||||
* page that never had weather on. Writes <out>/<label>/results.json.
|
||||
*
|
||||
* node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google
|
||||
*/
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
||||
? argv[i + 1]
|
||||
: fallback;
|
||||
};
|
||||
const flag = (name) => argv.includes(name);
|
||||
const rawUrl = arg('--url', 'http://localhost:4173');
|
||||
const url = rawUrl.includes('welcome=')
|
||||
? rawUrl
|
||||
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
||||
const label = arg('--label', 'teardown');
|
||||
const outDir = path.resolve(arg('--out', 'qa-shots/weather-teardown'), label);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const WEATHER = [
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
];
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: flag('--headless') ? 'new' : false,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
||||
existsSync(p),
|
||||
),
|
||||
protocolTimeout: 300_000,
|
||||
defaultViewport: null,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--window-size=1400,1000',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-background-timer-throttling',
|
||||
],
|
||||
});
|
||||
|
||||
const boot = async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) =>
|
||||
errors.push(`pageerror: ${e.message}`.slice(0, 200)),
|
||||
);
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200));
|
||||
});
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
await sleep(10_000);
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const dm = gev.dataManager;
|
||||
for (const [id, e] of dm.layers)
|
||||
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
|
||||
const v = gev.viewer;
|
||||
const d = Math.PI / 180;
|
||||
v.camera.setView({
|
||||
destination: v.scene.ellipsoid.cartographicToCartesian({
|
||||
longitude: -95 * d,
|
||||
latitude: 32 * d,
|
||||
height: 4_500_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
|
||||
});
|
||||
});
|
||||
await sleep(3000);
|
||||
return { page, errors };
|
||||
};
|
||||
|
||||
const snapshot = (page) =>
|
||||
page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const s = v.scene;
|
||||
const t = gev.tileset;
|
||||
const dm = gev.dataManager;
|
||||
const weather = {};
|
||||
for (const id of [
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
]) {
|
||||
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
|
||||
if (d)
|
||||
weather[id] = {
|
||||
imagery: d.imageryCount,
|
||||
mosaics: d.cache?.mosaics ?? 0,
|
||||
prefetching: !!d.cache?.prefetching,
|
||||
};
|
||||
}
|
||||
const wind = dm.layers.get('wind')?.module?.getDiagnostics?.() || {};
|
||||
return {
|
||||
globeImagery: v.imageryLayers.length,
|
||||
tilesetImagery: t?.imageryLayers?.length ?? null,
|
||||
primitives: s.primitives.length,
|
||||
groundPrimitives: s.groundPrimitives.length,
|
||||
dataSources: v.dataSources.length,
|
||||
entities: v.entities.values.length,
|
||||
listeners: {
|
||||
postRender: s.postRender.numberOfListeners,
|
||||
preRender: s.preRender.numberOfListeners,
|
||||
postUpdate: s.postUpdate.numberOfListeners,
|
||||
moveEnd: v.camera.moveEnd.numberOfListeners,
|
||||
moveStart: v.camera.moveStart.numberOfListeners,
|
||||
changed: v.camera.changed.numberOfListeners,
|
||||
tick: v.clock.onTick.numberOfListeners,
|
||||
},
|
||||
dom: {
|
||||
weatherPanelHidden:
|
||||
document.getElementById('weather-panel')?.hidden ?? null,
|
||||
weatherCards: document.querySelectorAll('.weather-card').length,
|
||||
windReading: !!document.querySelector(
|
||||
'.gev-wind-reading:not([hidden])',
|
||||
),
|
||||
},
|
||||
weather,
|
||||
windCells: wind.gpu?.pathCount ?? wind.paths ?? null,
|
||||
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
|
||||
};
|
||||
});
|
||||
|
||||
const sample = async (page, seconds) => {
|
||||
await page.evaluate(() => {
|
||||
window.__qaRenders = 0;
|
||||
window.__qaLong = { count: 0, ms: 0 };
|
||||
const s = window.__godsEyeView.viewer.scene;
|
||||
window.__qaOff = s.postRender.addEventListener(() => window.__qaRenders++);
|
||||
window.__qaObs = new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
window.__qaLong.count++;
|
||||
window.__qaLong.ms += e.duration;
|
||||
}
|
||||
});
|
||||
window.__qaObs.observe({ entryTypes: ['longtask'] });
|
||||
window.__qaRaf = () => {
|
||||
s.requestRender();
|
||||
window.__qaRafId = requestAnimationFrame(window.__qaRaf);
|
||||
};
|
||||
window.__qaRaf();
|
||||
});
|
||||
await sleep(seconds * 1000);
|
||||
return page.evaluate((seconds) => {
|
||||
cancelAnimationFrame(window.__qaRafId);
|
||||
window.__qaOff();
|
||||
window.__qaObs.disconnect();
|
||||
return {
|
||||
fps: +(window.__qaRenders / seconds).toFixed(1),
|
||||
longTasks: window.__qaLong.count,
|
||||
longMs: Math.round(window.__qaLong.ms),
|
||||
};
|
||||
}, seconds);
|
||||
};
|
||||
|
||||
const setLayer = (page, id, on) =>
|
||||
page.evaluate(
|
||||
async ({ id, on }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const e = dm.layers.get(id);
|
||||
if (e && Boolean(e.enabled) !== on)
|
||||
await dm.setEnabled(id, on, { origin: 'user' });
|
||||
return !!e;
|
||||
},
|
||||
{ id, on },
|
||||
);
|
||||
|
||||
const diff = (a, b) => {
|
||||
const out = {};
|
||||
const walk = (x, y, prefix) => {
|
||||
for (const key of Object.keys(x)) {
|
||||
const p = prefix ? `${prefix}.${key}` : key;
|
||||
if (x[key] && typeof x[key] === 'object' && !Array.isArray(x[key]))
|
||||
walk(x[key], y?.[key] || {}, p);
|
||||
else if (JSON.stringify(x[key]) !== JSON.stringify(y?.[key]))
|
||||
out[p] = [x[key], y?.[key]];
|
||||
}
|
||||
};
|
||||
walk(a, b, '');
|
||||
return out;
|
||||
};
|
||||
|
||||
const results = { url: rawUrl, label, startedAt: new Date().toISOString() };
|
||||
try {
|
||||
// ── Run A: weather on → exercised → off → baseline check → other layers ──
|
||||
const a = await boot();
|
||||
const others = await a.page.evaluate(() =>
|
||||
[...window.__godsEyeView.dataManager.layers.keys()].filter((id) =>
|
||||
/^flights$|^satellites$|vessel|firms|earthquake|cables|^military$/.test(
|
||||
id,
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log('other layers under test:', others.join(', '));
|
||||
const baseline = await snapshot(a.page);
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
for (const id of WEATHER) await setLayer(a.page, id, true);
|
||||
await a.page
|
||||
.waitForFunction(
|
||||
() =>
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
|
||||
(id) => {
|
||||
const d = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
?.module?.getDiagnostics?.();
|
||||
return d && !d.loading && d.time;
|
||||
},
|
||||
),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.catch(() => console.log(' !! weather not ready'));
|
||||
await sleep(2000);
|
||||
// exercise: history playback, wind reading, storm selection, product switch
|
||||
await a.page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
||||
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
|
||||
dm.setLayerParams('wind', { inspect: true }, { origin: 'user' });
|
||||
dm.setLayerParams(
|
||||
'weather-satellite',
|
||||
{ product: 'clouds' },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
});
|
||||
await sleep(8000);
|
||||
await a.page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const storms =
|
||||
dm.layers.get('weather-cyclones')?.module?.getRowControls?.().list
|
||||
?.items || [];
|
||||
if (storms[1])
|
||||
dm.setLayerParams('weather-cyclones', storms[1].params, {
|
||||
origin: 'user',
|
||||
});
|
||||
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
||||
});
|
||||
await sleep(4000);
|
||||
const weatherOn = await snapshot(a.page);
|
||||
console.log('weather on', JSON.stringify(weatherOn));
|
||||
results.weatherOnSample = await sample(a.page, 5);
|
||||
for (const id of WEATHER) await setLayer(a.page, id, false);
|
||||
await sleep(4000);
|
||||
const afterOff = await snapshot(a.page);
|
||||
const leak = diff(
|
||||
{ ...baseline, enabled: undefined, weather: undefined },
|
||||
{ ...afterOff, enabled: undefined, weather: undefined },
|
||||
);
|
||||
results.baseline = baseline;
|
||||
results.afterOff = afterOff;
|
||||
results.leak = leak;
|
||||
console.log('after off', JSON.stringify(afterOff));
|
||||
console.log(
|
||||
Object.keys(leak).length
|
||||
? `!! DIFFERENCES after teardown: ${JSON.stringify(leak)}`
|
||||
: 'teardown: scene back to baseline',
|
||||
);
|
||||
results.afterWeather = [];
|
||||
for (const id of others) {
|
||||
const ok = await setLayer(a.page, id, true);
|
||||
await sleep(9000);
|
||||
const m = await sample(a.page, 5);
|
||||
const snap = await snapshot(a.page);
|
||||
results.afterWeather.push({
|
||||
id,
|
||||
ok,
|
||||
...m,
|
||||
primitives: snap.primitives,
|
||||
dataSources: snap.dataSources,
|
||||
});
|
||||
console.log(
|
||||
`after weather + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
||||
);
|
||||
}
|
||||
results.errorsA = a.errors;
|
||||
await a.page.screenshot({ path: path.join(outDir, 'a-others-on.png') });
|
||||
await a.page.close();
|
||||
|
||||
// ── Run B: control (same others, never had weather) ──
|
||||
const b = await boot();
|
||||
results.control = [];
|
||||
for (const id of others) {
|
||||
const ok = await setLayer(b.page, id, true);
|
||||
await sleep(9000);
|
||||
const m = await sample(b.page, 5);
|
||||
const snap = await snapshot(b.page);
|
||||
results.control.push({
|
||||
id,
|
||||
ok,
|
||||
...m,
|
||||
primitives: snap.primitives,
|
||||
dataSources: snap.dataSources,
|
||||
});
|
||||
console.log(
|
||||
`control + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
||||
);
|
||||
}
|
||||
results.errorsB = b.errors;
|
||||
await b.page.screenshot({ path: path.join(outDir, 'b-control.png') });
|
||||
await b.page.close();
|
||||
|
||||
console.log(
|
||||
'\nerrors (weather run):',
|
||||
results.errorsA.length ? results.errorsA : 'none',
|
||||
);
|
||||
console.log(
|
||||
'errors (control run):',
|
||||
results.errorsB.length ? results.errorsB : 'none',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(outDir, 'results.json'),
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
console.log(`wrote ${path.join(outDir, 'results.json')}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Real-canvas wind regression. Run against this candidate's dev server. */
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), headless: false, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' });
|
||||
const result = await page.evaluate(async () => {
|
||||
const { createWindRendering } = await import('/src/layers/wind/rendering.js');
|
||||
const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame;
|
||||
const callbacks = new Map(); let sequence = 0;
|
||||
window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; };
|
||||
window.cancelAnimationFrame = id => callbacks.delete(id);
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px;z-index:9999;background:#102030';
|
||||
document.body.appendChild(container);
|
||||
const sceneCanvas = { clientWidth: 800, clientHeight: 600 };
|
||||
const viewer = { scene: { canvas: sceneCanvas, camera: {} }, isDestroyed: () => false };
|
||||
const cesium = { Cartesian3: { fromDegrees: (lon,lat) => ({ lon,lat }) }, SceneTransforms: { worldToWindowCoordinates: (scene, point) => ({ x: 400 + point.lon, y: 300 + point.lat }) } };
|
||||
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
|
||||
try {
|
||||
rendering.attach(); rendering.start();
|
||||
const idleCallbacks = callbacks.size;
|
||||
rendering.setField({ grid: { nx:1,ny:1,lo1:0,la1:90,dx:360,dy:180 }, u: new Float32Array([10]), v: new Float32Array([0]) });
|
||||
const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
|
||||
const tick = time => { const [id, callback] = callbacks.entries().next().value; callbacks.delete(id); callback(time); };
|
||||
tick(16); // First camera signature clears the surface.
|
||||
context.fillStyle = '#ff0000'; context.fillRect(10,10,5,5);
|
||||
tick(32);
|
||||
const alpha = context.getImageData(12,12,1,1).data[3];
|
||||
const count = rendering.getParticleCount();
|
||||
sceneCanvas.clientWidth = 320; sceneCanvas.clientHeight = 240;
|
||||
tick(48);
|
||||
const resizedCount = rendering.getParticleCount();
|
||||
rendering.stop(); rendering.clear();
|
||||
return { idleCallbacks, alpha, count, resizedCount, pending: callbacks.size, afterClear: context.getImageData(12,12,1,1).data[3] };
|
||||
} finally { rendering.destroy(); container.remove(); window.requestAnimationFrame = raf; window.cancelAnimationFrame = caf; }
|
||||
});
|
||||
assert.equal(result.idleCallbacks, 0);
|
||||
assert.ok(result.alpha > 200, 'unchanged canvas dimensions preserve existing trail pixels');
|
||||
assert.ok(result.resizedCount < result.count);
|
||||
assert.equal(result.pending, 0); assert.equal(result.afterClear, 0);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { await browser.close(); }
|
||||
@@ -8,6 +8,8 @@ import { projectRoot } from './project-root.mjs';
|
||||
import { selectMapStartupRoute } from '../src/mapStartup.js';
|
||||
|
||||
const ROOT = projectRoot(import.meta.url);
|
||||
const OPENSKY_AUTH_MODE_DEFAULT = 'oauth';
|
||||
const OPENSKY_AUTH_MODES = new Set(['basic', 'oauth', 'auto', 'anon']);
|
||||
|
||||
export const CREDENTIALS = Object.freeze([
|
||||
{ name: 'GOOGLE_MAPS_API_KEY', label: 'Google Maps', keychain: [['google-maps-api', 'api-key'], ['google-maps-api', 'default'], ['google-maps-api', 'key']] },
|
||||
@@ -101,6 +103,21 @@ export function readDoctorDotenvValue(
|
||||
return String(values[key] ?? '');
|
||||
}
|
||||
|
||||
/** Resolve the OpenSky auth mode using the same environment and dotenv precedence as the launcher. */
|
||||
export function resolveOpenSkyAuthMode({
|
||||
authoritativeEnvironment = false,
|
||||
environment = process.env,
|
||||
rootDir = ROOT,
|
||||
} = {}) {
|
||||
const environmentDefinesMode = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_AUTH_MODE');
|
||||
const environmentMode = String(environment.OPENSKY_AUTH_MODE || '').trim();
|
||||
const dotenvMode = authoritativeEnvironment && environmentDefinesMode
|
||||
? ''
|
||||
: readDoctorDotenvValue('OPENSKY_AUTH_MODE', rootDir);
|
||||
const mode = (environmentMode || dotenvMode).trim().toLowerCase();
|
||||
return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT;
|
||||
}
|
||||
|
||||
function hasKeychainItem(service, account) {
|
||||
if (process.platform !== 'darwin') return false;
|
||||
const result = spawnSync('security', [
|
||||
@@ -128,8 +145,25 @@ export function resolveCredential(spec, {
|
||||
return { configured: false, source: null };
|
||||
}
|
||||
|
||||
export function buildCapabilitySummary(credentials) {
|
||||
export function buildCapabilitySummary(
|
||||
credentials,
|
||||
{ openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {},
|
||||
) {
|
||||
const configured = (name) => credentials[name]?.configured === true;
|
||||
const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET');
|
||||
const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase();
|
||||
const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode)
|
||||
? requestedOpenSkyMode
|
||||
: OPENSKY_AUTH_MODE_DEFAULT;
|
||||
const flights = openSkyMode === 'anon'
|
||||
? 'OpenSky keyless anonymous access (rate-limited)'
|
||||
: openSkyMode === 'basic'
|
||||
? 'OpenSky Basic mode selected (credential presence and validity not verified)'
|
||||
: openSkyMode === 'auto'
|
||||
? 'OpenSky auto mode selected (runtime credential choice and validity not verified)'
|
||||
: hasOAuthCredentials
|
||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||
: 'OpenSky keyless anonymous access (rate-limited)';
|
||||
const route = selectMapStartupRoute({
|
||||
googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '',
|
||||
cesiumToken: configured('CESIUM_ION_TOKEN') ? 'configured' : '',
|
||||
@@ -140,9 +174,7 @@ export function buildCapabilitySummary(credentials) {
|
||||
: route === 'google-ion'
|
||||
? 'Google Photorealistic 3D Tiles through Cesium ion; Bing and world-terrain stacks available'
|
||||
: 'Esri World Imagery (keyless satellite basemap) with keyless terrain',
|
||||
flights: configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET')
|
||||
? 'OpenSky OAuth credentials present (runtime mode and validity not verified)'
|
||||
: 'OpenSky OAuth credentials not configured',
|
||||
flights,
|
||||
voice: configured('OPENAI_API_KEY') ? 'available' : 'off until an OpenAI key is added',
|
||||
vessels: configured('AISSTREAM_API_KEY') ? 'live AISStream feed' : 'off until an AISStream key is added',
|
||||
fires: configured('FIRMS_MAP_KEY') ? 'live NASA FIRMS feed' : 'off until a FIRMS key is added',
|
||||
@@ -173,7 +205,9 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment
|
||||
: { available: false, version: null },
|
||||
dependenciesInstalled,
|
||||
credentials,
|
||||
capabilities: buildCapabilitySummary(credentials),
|
||||
capabilities: buildCapabilitySummary(credentials, {
|
||||
openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
* --source-base <path> Browser module prefix (default /src)
|
||||
* --url <url> App URL (default http://localhost:4173)
|
||||
* --headful Show the browser (debugging)
|
||||
* --offline-imagery Use bundled texture for known Esri/OSM tile images only
|
||||
* --keep-open Leave the browser open after the run (debugging)
|
||||
*/
|
||||
|
||||
@@ -110,6 +111,7 @@ 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');
|
||||
const OFFLINE_IMAGERY = getFlag('--offline-imagery');
|
||||
const KEEP_OPEN = getFlag('--keep-open');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -201,6 +203,15 @@ async function main() {
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
|
||||
// Tracking does not assert imagery geography. This optional fixture leaves
|
||||
// provider metadata, terrain, models and every tracking assertion untouched.
|
||||
// Read before launching so a missing bundled asset fails explicitly.
|
||||
const offlineImageryTile = OFFLINE_IMAGERY
|
||||
? fs.readFileSync(new URL('../node_modules/cesium/Build/Cesium/Assets/Textures/NaturalEarthII/0/0/0.jpg', import.meta.url))
|
||||
: null;
|
||||
let offlineImageryRequests = 0;
|
||||
if (OFFLINE_IMAGERY) console.log(' Imagery : bundled NaturalEarthII fixture (tile images only)');
|
||||
|
||||
// Verify the dev server is up before launching a browser.
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
@@ -256,6 +267,25 @@ async function main() {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const url = new URL(request.url());
|
||||
// Reuse the one existing interceptor; do not install competing handlers.
|
||||
// Exact HTTPS origins and image paths only: metadata, feature queries,
|
||||
// other providers, terrain and local/model resources continue normally.
|
||||
const knownImageryTile = request.method() === 'GET' && (
|
||||
(url.origin === 'https://services.arcgisonline.com'
|
||||
&& /^\/ArcGIS\/rest\/services\/World_Imagery\/MapServer\/tile\/\d+\/\d+\/\d+$/.test(url.pathname))
|
||||
|| (url.origin === 'https://tile.openstreetmap.org'
|
||||
&& /^\/\d+\/\d+\/\d+\.png$/.test(url.pathname))
|
||||
);
|
||||
if (OFFLINE_IMAGERY && knownImageryTile) {
|
||||
offlineImageryRequests++;
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: offlineImageryTile,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
|
||||
request.respond({
|
||||
status: 200,
|
||||
@@ -365,6 +395,19 @@ async function main() {
|
||||
lastMessageAt: null,
|
||||
}));
|
||||
}
|
||||
// Contacts also enables mapped installations. This tracking harness
|
||||
// does not test installation acquisition; a valid empty viewport keeps
|
||||
// public Overpass outages out of aircraft/satellite tracking results.
|
||||
// Preserve the real response shape and all console-error assertions.
|
||||
if (isAppRequest && url.pathname === '/api/military-installations') {
|
||||
return Promise.resolve(jsonResponse({
|
||||
elements: [],
|
||||
saturated: false,
|
||||
elementCap: 700,
|
||||
retrievedAt: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
|
||||
// the satellite assertions are deterministic and the run never depends
|
||||
// on an upstream that rate-limits (it was answering 403 the night this
|
||||
@@ -4082,6 +4125,7 @@ async function main() {
|
||||
|
||||
finishAndExit();
|
||||
} finally {
|
||||
if (OFFLINE_IMAGERY) console.log(` Bundled imagery tiles served: ${offlineImageryRequests}`);
|
||||
if (!KEEP_OPEN) {
|
||||
await browser.close();
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user