mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Compare commits
385
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef29781e5 | ||
|
|
69fbf5b38d | ||
|
|
68845a4977 | ||
|
|
b4556f9763 | ||
|
|
7f478e3815 | ||
|
|
371d848eec | ||
|
|
18f69c2699 | ||
|
|
e1095ee553 | ||
|
|
aa6959c97b | ||
|
|
be08163caa | ||
|
|
db4bdba64f | ||
|
|
fbadf792d8 | ||
|
|
fd65288101 | ||
|
|
7f3754d812 | ||
|
|
c1e857f798 | ||
|
|
3814221cbc | ||
|
|
26597ad25c | ||
|
|
0459ee6d58 | ||
|
|
4252ed0798 | ||
|
|
52c6f68bd5 | ||
|
|
656943641c | ||
|
|
f385986383 | ||
|
|
dacfd33f10 | ||
|
|
910d693b94 | ||
|
|
f4b83a3a31 | ||
|
|
3907b7084e | ||
|
|
97c4dc76ce | ||
|
|
c079b329d0 | ||
|
|
c73c5382d4 | ||
|
|
b53b6a48a7 | ||
|
|
47d63128ff | ||
|
|
48885d9e61 | ||
|
|
f1ec9d4a22 | ||
|
|
406dd9122e | ||
|
|
1559bd57f4 | ||
|
|
2fbdca62be | ||
|
|
30c86a6001 | ||
|
|
e18f12d34c | ||
|
|
54fe0aea40 | ||
|
|
974a0b6361 | ||
|
|
6dc643182e | ||
|
|
dc1144b947 | ||
|
|
fc75e245a6 | ||
|
|
145adc08e0 | ||
|
|
f06b93fbc9 | ||
|
|
3e261d1baf | ||
|
|
35e70ec280 | ||
|
|
48988015f1 | ||
|
|
2213be6566 | ||
|
|
59a233db51 | ||
|
|
9daa4bd2db | ||
|
|
92c8f8d9da | ||
|
|
90166024e9 | ||
|
|
9825b02aab | ||
|
|
a6c64713ee | ||
|
|
7abf74518f | ||
|
|
c502404e1d | ||
|
|
350a7f5abb | ||
|
|
0ee92eb864 | ||
|
|
909be62ae7 | ||
|
|
8bea1419f6 | ||
|
|
e1d3a588fc | ||
|
|
843c4a111d | ||
|
|
43b4e9348b | ||
|
|
727bb29cae | ||
|
|
0c0df50a40 | ||
|
|
db9bf64821 | ||
|
|
a2f5b8fca3 | ||
|
|
06ae95ceb3 | ||
|
|
bc1b56b63a | ||
|
|
b3efcfb7a2 | ||
|
|
1100cdcff5 | ||
|
|
f89aee1eac | ||
|
|
b8305de14b | ||
|
|
f87da8523e | ||
|
|
91685c94d9 | ||
|
|
a56f2f9352 | ||
|
|
43ab3ecaaf | ||
|
|
ebc717df8b | ||
|
|
726e201314 | ||
|
|
90d64e6916 | ||
|
|
cbe1d841ea | ||
|
|
b49f9dddf0 | ||
|
|
125fbc5a3b | ||
|
|
9cc108835e | ||
|
|
8d7f055572 | ||
|
|
227e3c2051 | ||
|
|
ae13a56f6d | ||
|
|
9f8381ff71 | ||
|
|
01ae045346 | ||
|
|
59a2e31aa8 | ||
|
|
b20579b06a | ||
|
|
a9282b30bb | ||
|
|
1a3f6f2486 | ||
|
|
c2d7a6dbb0 | ||
|
|
89339cdd96 | ||
|
|
5983c663bd | ||
|
|
ef174b0ff7 | ||
|
|
6ba67d01af | ||
|
|
d8ef366ab1 | ||
|
|
f0d38897f4 | ||
|
|
d8c63c69d8 | ||
|
|
eee4f48745 | ||
|
|
c392d6fb50 | ||
|
|
074f4af10e | ||
|
|
dcb8de9d2c | ||
|
|
4c28cc0212 | ||
|
|
00dfed7959 | ||
|
|
f7945d6844 | ||
|
|
96b5b0bb1f | ||
|
|
5b7171f485 | ||
|
|
f5c14ec30d | ||
|
|
dea10cac4f | ||
|
|
60a230b2ff | ||
|
|
057dbe24af | ||
|
|
f0d7def9c7 | ||
|
|
1773494116 | ||
|
|
3144169057 | ||
|
|
37ea4e1f93 | ||
|
|
31b1f27a2e | ||
|
|
f2117a98f2 | ||
|
|
40a81c58e6 | ||
|
|
df73c5b21a | ||
|
|
c96d4ecfb1 | ||
|
|
d5218c4722 | ||
|
|
4f8a9f2615 | ||
|
|
1c09c90e13 | ||
|
|
dae4011604 | ||
|
|
299a5943a0 | ||
|
|
f7cd6797ec | ||
|
|
1910a3b2d5 | ||
|
|
c45ec634ee | ||
|
|
d86047fe8f | ||
|
|
58c8a481ed | ||
|
|
63b2590b9f | ||
|
|
4a9b95e31b | ||
|
|
b977ef29e3 | ||
|
|
293ffb2bd3 | ||
|
|
b62f60482e | ||
|
|
b29e20632b | ||
|
|
736ee8d694 | ||
|
|
03cd379c0e | ||
|
|
57f7edc693 | ||
|
|
c34a2c38fc | ||
|
|
8e1a3bab3e | ||
|
|
a99b862580 | ||
|
|
2bac200c01 | ||
|
|
061ed1b682 | ||
|
|
d1fdb9eb45 | ||
|
|
741b6f67eb | ||
|
|
17eea266f1 | ||
|
|
111ba1626c | ||
|
|
05578c61b9 | ||
|
|
35ed499ae4 | ||
|
|
f0c2523ac4 | ||
|
|
b2cc00833d | ||
|
|
5564156c09 | ||
|
|
eed8acc3f7 | ||
|
|
f4879fc24b | ||
|
|
7f0572cf4c | ||
|
|
1a1eef0e5c | ||
|
|
a066a2d1ca | ||
|
|
9cc34e324f | ||
|
|
cf6d04b7db | ||
|
|
7fb11507d5 | ||
|
|
53c114965d | ||
|
|
65d41b971c | ||
|
|
4d2b061e3b | ||
|
|
64c5cd25e6 | ||
|
|
64759c552e | ||
|
|
12cd73bed9 | ||
|
|
baabff6acf | ||
|
|
2853938df7 | ||
|
|
dd040b4f87 | ||
|
|
0c190c3a15 | ||
|
|
62296a833c | ||
|
|
05b646daa3 | ||
|
|
14f7b32537 | ||
|
|
160e445677 | ||
|
|
a49671a391 | ||
|
|
133ca14119 | ||
|
|
24190a3e35 | ||
|
|
c4a97a653c | ||
|
|
b01f1ee7dc | ||
|
|
b98cbeebf2 | ||
|
|
75df3389f3 | ||
|
|
4e09559337 | ||
|
|
811bcaff85 | ||
|
|
605b9490f3 | ||
|
|
5cea3e8cbb | ||
|
|
2b4955c817 | ||
|
|
d7b3d45135 | ||
|
|
c1d829ef51 | ||
|
|
868d061856 | ||
|
|
cb350fa58d | ||
|
|
11992828fb | ||
|
|
87bbe14748 | ||
|
|
81bcae907f | ||
|
|
e08ddd787b | ||
|
|
1bda760763 | ||
|
|
b7431d7823 | ||
|
|
d7ab85f5c4 | ||
|
|
2ee09c5911 | ||
|
|
1930ca247a | ||
|
|
6fe5bda028 | ||
|
|
99bda8a35e | ||
|
|
c0062c4d3d | ||
|
|
a0e5f04c30 | ||
|
|
4bd7f97cc4 | ||
|
|
73ea608881 | ||
|
|
2087b55b47 | ||
|
|
8b179a7e01 | ||
|
|
96ce795f8c | ||
|
|
3a2a53aef9 | ||
|
|
0d15e80e28 | ||
|
|
3cbc17a926 | ||
|
|
5e658d8c9d | ||
|
|
f71c76c306 | ||
|
|
b8f4d097c4 | ||
|
|
1da65dc264 | ||
|
|
dc7a7c4d53 | ||
|
|
15f1f9f5b6 | ||
|
|
c76872dfaa | ||
|
|
8279af2e6d | ||
|
|
0df0fb8261 | ||
|
|
8328aab779 | ||
|
|
9ba5a3aba9 | ||
|
|
f59d134928 | ||
|
|
5a8bc33fbd | ||
|
|
b8560e5b26 | ||
|
|
694616d5c2 | ||
|
|
798d6636ab | ||
|
|
93c71ffc86 | ||
|
|
f72bf6a774 | ||
|
|
311e178d0c | ||
|
|
a72cfc4098 | ||
|
|
46b3aceafc | ||
|
|
9d587c1d37 | ||
|
|
d82a6f1550 | ||
|
|
1a694baf5a | ||
|
|
965a82144c | ||
|
|
f4f0088814 | ||
|
|
c58bc9864b | ||
|
|
5dd05a4a81 | ||
|
|
a9b3125a99 | ||
|
|
a3152ab719 | ||
|
|
c89185f352 | ||
|
|
e8870a4766 | ||
|
|
0600964249 | ||
|
|
766c786093 | ||
|
|
4baefeb8f9 | ||
|
|
1839d62160 | ||
|
|
6bdb995fe3 | ||
|
|
000ef99464 | ||
|
|
83fbe6949f | ||
|
|
c8a4f3ed98 | ||
|
|
69a78710cc | ||
|
|
620ada6df3 | ||
|
|
1fe8267550 | ||
|
|
6b27b21cc4 | ||
|
|
6eec8f6cb0 | ||
|
|
71b7177dc0 | ||
|
|
5ff8c5d7a6 | ||
|
|
ed9ea7e679 | ||
|
|
ea1e2f9c04 | ||
|
|
65e30546dd | ||
|
|
0b8cb855fa | ||
|
|
941b308c29 | ||
|
|
10d0e49ddb | ||
|
|
9dc446bd24 | ||
|
|
3a0a3b0551 | ||
|
|
7c0891deda | ||
|
|
e007350645 | ||
|
|
2da7b6d0d9 | ||
|
|
dcfb1219d5 | ||
|
|
ba1191b2e5 | ||
|
|
132a941e9a | ||
|
|
03a0ff3a01 | ||
|
|
b14bf7e013 | ||
|
|
20e9d4a0fa | ||
|
|
1dd3220812 | ||
|
|
443c581280 | ||
|
|
4dfcba8989 | ||
|
|
d81ebf1268 | ||
|
|
2ce1c55f69 | ||
|
|
7f13ef4a87 | ||
|
|
8e702f0c3b | ||
|
|
9f1a0611fa | ||
|
|
c493a8303b | ||
|
|
bf46eb6d97 | ||
|
|
907cb2092d | ||
|
|
ba039f7974 | ||
|
|
18b5475b8a | ||
|
|
bc281ab246 | ||
|
|
ec7dcc944e | ||
|
|
834d34296f | ||
|
|
d6fe0f3805 | ||
|
|
f0ce1b04c7 | ||
|
|
b0cbed081b | ||
|
|
e1be2eac19 | ||
|
|
7d5a484796 | ||
|
|
311722e6de | ||
|
|
d7193e3b9e | ||
|
|
a4e753d550 | ||
|
|
4e195dac6b | ||
|
|
68ff4e1c25 | ||
|
|
645f042450 | ||
|
|
f640890cf6 | ||
|
|
a89719aba9 | ||
|
|
39d522c9a9 | ||
|
|
d483e5897e | ||
|
|
ae52695b15 | ||
|
|
6436f580d9 | ||
|
|
0957d9712b | ||
|
|
93866c42fc | ||
|
|
e804351205 | ||
|
|
3c2e924363 | ||
|
|
8812117013 | ||
|
|
c327a25744 | ||
|
|
b0cb8ce881 | ||
|
|
86aa0bc9ba | ||
|
|
0109097440 | ||
|
|
4623c8b616 | ||
|
|
76851ee949 | ||
|
|
f7f4e9a45c | ||
|
|
7c7422e281 | ||
|
|
4aaf974600 | ||
|
|
ce985768bc | ||
|
|
da6141be4b | ||
|
|
6dad3894bd | ||
|
|
5188e8869d | ||
|
|
bb4e6bafa4 | ||
|
|
c33ee31efc | ||
|
|
f1a2df5cfe | ||
|
|
27e28373d0 | ||
|
|
26968f4903 | ||
|
|
d50eaa28af | ||
|
|
1cca89adf2 | ||
|
|
a8a2ad5582 | ||
|
|
abf16ae8af | ||
|
|
bb8c0b83c4 | ||
|
|
ac081a008d | ||
|
|
177446c0be | ||
|
|
179c846f3e | ||
|
|
80cc0b536e | ||
|
|
312ed416e9 | ||
|
|
b65dbab709 | ||
|
|
8270172086 | ||
|
|
64d3297301 | ||
|
|
4ded3fbf15 | ||
|
|
306e3e9467 | ||
|
|
72fec292d4 | ||
|
|
d112ecf296 | ||
|
|
c6e1b65c67 | ||
|
|
0b9deddd70 | ||
|
|
ec38e6ef16 | ||
|
|
16e63f4c50 | ||
|
|
a3e929870d | ||
|
|
840e34a881 | ||
|
|
a4ac0b9b0d | ||
|
|
90c8e7763a | ||
|
|
b6ac0e9c99 | ||
|
|
6dbd9c983e | ||
|
|
ab0acb0e09 | ||
|
|
12c9651a67 | ||
|
|
3e3aa0bd9f | ||
|
|
83c13f330d | ||
|
|
be56144d75 | ||
|
|
c2a0d20f3d | ||
|
|
aaa6074663 | ||
|
|
58a196374f | ||
|
|
a5c98c6b07 | ||
|
|
ab2368f3f3 | ||
|
|
d831cbd23a | ||
|
|
cd94a26b9f | ||
|
|
708c96b6f6 | ||
|
|
528ec75f32 | ||
|
|
f2fcd82290 | ||
|
|
e3061872cc | ||
|
|
a915bdb3e5 | ||
|
|
ee8b94d987 | ||
|
|
2b83606765 | ||
|
|
1a69368ae6 | ||
|
|
d886be68a8 | ||
|
|
4a92ed8377 |
No files matched your search
@@ -59,6 +59,10 @@ jobs:
|
||||
- name: Build production bundle
|
||||
run: npm run build
|
||||
|
||||
- name: Main chunk size budget
|
||||
if: matrix.node == '24.22.2'
|
||||
run: npm run check:main-chunk
|
||||
|
||||
street-level-browser:
|
||||
# Hermetic: every Mapillary request is answered from fixtures, so the
|
||||
# dummy token below never leaves the page.
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the
|
||||
How to read this:
|
||||
|
||||
- **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder.
|
||||
- **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. **EOX Sentinel-2 cloudless 2020** is also **NonCommercial** (CC BY-NC-SA 4.0): remove that basemap or obtain a commercial licence from EOX.
|
||||
- **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes.
|
||||
- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status.
|
||||
|
||||
@@ -33,6 +33,7 @@ How to read this:
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **EOX Sentinel-2 cloudless 2020** | Optional satellite basemap in Cesium and Navara. Navara uses daylight for terrain inspection. | [EOX Maps](https://maps.eox.at/); [CC BY-NC-SA 4.0](https://cloudless.eox.at/license-non-commercial). Commercial use requires a commercial licence from EOX. | [EOxCloudless](https://cloudless.eox.at/) by [EOX IT Services GmbH](https://eox.at/) (Contains modified Copernicus Sentinel data 2020) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
|
||||
|
||||
@@ -340,6 +340,8 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
||||
7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_
|
||||
8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_
|
||||
|
||||
Open **Visual Presets → Globe Engine** to switch between Cesium and experimental Navara. The globe switches in place at the current view and remembers your choice. A link with `?engine=cesium` or `?engine=navara` overrides the saved choice. A fresh visit starts with Cesium.
|
||||
|
||||
**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe.
|
||||
|
||||
---
|
||||
|
||||
+22
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
The project depends on third-party npm packages whose own licenses apply. This
|
||||
file records the packages added for the browser-local SDR feature and the
|
||||
Street Level viewer; the complete resolved dependency inventory remains in
|
||||
`package-lock.json`.
|
||||
Street Level viewer, and the CesiumJS code ported into `src/geo/`; the complete
|
||||
resolved dependency inventory remains in `package-lock.json`.
|
||||
|
||||
## Web RTL-SDR
|
||||
|
||||
@@ -45,3 +45,23 @@ Street Level viewer; the complete resolved dependency inventory remains in
|
||||
- Source: <https://github.com/mrdoob/three.js>
|
||||
- License: MIT
|
||||
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||
|
||||
## CesiumJS (portions ported to src/geo)
|
||||
|
||||
- Package: `cesium` 1.138.0 (`@cesium/engine` 22.3.0); the files in `src/geo/`
|
||||
are adapted from `@cesium/engine/Source/Core` (`Math.js`, `Ellipsoid.js`,
|
||||
`scaleToGeodeticSurface.js`, `Cartographic.js`, `Cartesian2.js`,
|
||||
`Cartesian3.js`, `Matrix3.js`, `Matrix4.js`, `Quaternion.js`,
|
||||
`Transforms.js`, `BoundingSphere.js`, `EllipsoidGeodesic.js`, `JulianDate.js`,
|
||||
`Simon1994PlanetaryPositions.js`) and say so in their first
|
||||
line. The changes: the classes' static functions became module functions,
|
||||
vectors, geodetic positions and quaternions became plain-data instances of
|
||||
module-private constructors (own enumerable fields only, no methods),
|
||||
matrices plain arrays, and the ellipsoid a frozen object; the debug
|
||||
argument checks were left out, as in Cesium's release build; only the subset
|
||||
gev uses was kept.
|
||||
- Author: Copyright 2011-2024 CesiumJS Contributors
|
||||
- Source: <https://github.com/CesiumGS/cesium>
|
||||
- License: Apache License 2.0
|
||||
- License text: `node_modules/@cesium/engine/LICENSE.md` (shipped with the
|
||||
dependency) and <https://www.apache.org/licenses/LICENSE-2.0>
|
||||
@@ -0,0 +1,58 @@
|
||||
// Build guard: the Cesium-mode page must not load three or Navara. The
|
||||
// initial chunks are every entry chunk and what it imports statically;
|
||||
// navaraMode.js reaches Navara only through import(), so three and
|
||||
// @navaramap/* belong to lazy chunks. This checks the bundle's module ids
|
||||
// (what Rollup put in each chunk), and fails the build otherwise.
|
||||
|
||||
/**
|
||||
* Module ids that only the Navara engine may load, when no package list is
|
||||
* given (the browser build passes the full dependency closure,
|
||||
* navaraDependencies.js).
|
||||
*/
|
||||
export const ENGINE_ONLY_MODULES =
|
||||
/[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/;
|
||||
|
||||
/**
|
||||
* Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk.
|
||||
* @param {Record<string, { type: string, isEntry?: boolean, imports?: string[], moduleIds?: string[] }>} bundle
|
||||
* @returns {{ chunk: string, module: string }[]}
|
||||
*/
|
||||
export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) {
|
||||
const initial = new Set();
|
||||
const visit = (fileName) => {
|
||||
const chunk = bundle[fileName];
|
||||
if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return;
|
||||
initial.add(fileName);
|
||||
for (const imported of chunk.imports ?? []) visit(imported);
|
||||
};
|
||||
for (const [fileName, chunk] of Object.entries(bundle))
|
||||
if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName);
|
||||
const violations = [];
|
||||
for (const fileName of [...initial].sort())
|
||||
for (const module of bundle[fileName].moduleIds ?? [])
|
||||
if (pattern.test(module)) violations.push({ chunk: fileName, module });
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */
|
||||
export function initialChunkGuard({
|
||||
log = console.log,
|
||||
pattern = ENGINE_ONLY_MODULES,
|
||||
} = {}) {
|
||||
return {
|
||||
name: 'gev-initial-chunk-guard',
|
||||
generateBundle(_options, bundle) {
|
||||
const violations = initialChunkViolations(bundle, pattern);
|
||||
if (violations.length) {
|
||||
this.error(
|
||||
'Navara-only modules in the Cesium-mode initial chunks:\n' +
|
||||
violations
|
||||
.map(({ chunk, module }) => ` ${chunk}: ${module}`)
|
||||
.join('\n'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
log('[initial-chunks] no Navara-only modules in the initial chunks');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/** Where @navaramap/three keeps its prebuilt worker assets, or null. */
|
||||
function defaultAssetsDir() {
|
||||
try {
|
||||
const entry = fileURLToPath(import.meta.resolve('@navaramap/three'));
|
||||
return path.join(path.dirname(entry), 'assets');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by
|
||||
* file name next to themselves (`new URL('x-hash.wasm', self.location)`), a
|
||||
* reference the bundler cannot see. When a build includes Navara (the
|
||||
* ?engine=navara chunk), copy every such wasm the bundle lacks into
|
||||
* `assets/` under its own name. A build without Navara gets none.
|
||||
* @param {{ assetsDir?: string | null }} [options]
|
||||
*/
|
||||
export function navaraWorkerAssets({ assetsDir } = {}) {
|
||||
return {
|
||||
name: 'navara-worker-assets',
|
||||
generateBundle(_options, bundle) {
|
||||
const usesNavara = Object.values(bundle).some(
|
||||
(file) => file.type === 'chunk' && file.code.includes('navara_wasm'),
|
||||
);
|
||||
if (!usesNavara) return;
|
||||
const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir;
|
||||
if (!dir || !existsSync(dir)) return;
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
if (!name.endsWith('.wasm')) continue;
|
||||
const fileName = `assets/${name}`;
|
||||
if (bundle[fileName]) continue;
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName,
|
||||
source: readFileSync(path.join(dir, name)),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NAVARA_MODULE = /\/src\/globe\/navara\//;
|
||||
|
||||
/**
|
||||
* Rollup chunkFileNames: the lazy chunk holding the Navara engine
|
||||
* (src/globe/navara, with the Navara packages it pulls in) is named
|
||||
* `navara-engine-[hash].js`; every other chunk keeps Vite's default name.
|
||||
* Naming only (not manualChunks, which would pull modules shared with the
|
||||
* Cesium entry into the Navara chunk), so the chunking is unchanged.
|
||||
* @param {{ isEntry?: boolean, moduleIds?: string[] }} chunk
|
||||
* @returns {string}
|
||||
*/
|
||||
export function navaraChunkFileNames(chunk) {
|
||||
const navara =
|
||||
!chunk.isEntry &&
|
||||
(chunk.moduleIds ?? []).some((id) =>
|
||||
NAVARA_MODULE.test(id.replaceAll('\\', '/')),
|
||||
);
|
||||
return navara ? 'assets/navara-engine-[hash].js' : 'assets/[name]-[hash].js';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// The packages only the Navara engine brings: the dependency closure of
|
||||
// @navaramap/three and @navaramap/three-default-plugin (dependencies and
|
||||
// peer dependencies, read from node_modules), without the packages the
|
||||
// app's other bundled dependencies reach too (Cesium is external), plus
|
||||
// three and postprocessing, which
|
||||
// the Cesium-mode page never loads statically either. The initial-chunk
|
||||
// guard (initialChunkGuard.js) fails the build when one of them is in an
|
||||
// initial chunk.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const NAVARA_ROOTS = Object.freeze([
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
]);
|
||||
const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']);
|
||||
// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only
|
||||
// Cesium reaches (draco3d, …) is not in the app's bundled graph.
|
||||
const EXTERNAL = Object.freeze(['cesium']);
|
||||
|
||||
function readManifest(root, name) {
|
||||
const file = path.join(root, 'node_modules', name, 'package.json');
|
||||
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
|
||||
}
|
||||
|
||||
/** Every package reachable from `names` through installed manifests. */
|
||||
export function dependencyClosure(root, names) {
|
||||
const seen = new Set();
|
||||
const queue = [...names];
|
||||
while (queue.length) {
|
||||
const name = queue.pop();
|
||||
if (seen.has(name)) continue;
|
||||
const manifest = readManifest(root, name);
|
||||
if (!manifest) continue;
|
||||
seen.add(name);
|
||||
for (const next of Object.keys({
|
||||
...manifest.dependencies,
|
||||
...manifest.peerDependencies,
|
||||
}))
|
||||
queue.push(next);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** The engine-only package names, sorted. */
|
||||
export function navaraOnlyPackages(root) {
|
||||
const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const navara = dependencyClosure(root, NAVARA_ROOTS);
|
||||
const appRoots = Object.keys(app.dependencies ?? {}).filter(
|
||||
(name) =>
|
||||
!navara.has(name) &&
|
||||
!ALWAYS_ENGINE_ONLY.includes(name) &&
|
||||
!EXTERNAL.includes(name),
|
||||
);
|
||||
const shared = dependencyClosure(root, appRoots);
|
||||
return [
|
||||
...new Set([
|
||||
...[...navara].filter((name) => !shared.has(name)),
|
||||
...ALWAYS_ENGINE_ONLY,
|
||||
]),
|
||||
].sort();
|
||||
}
|
||||
|
||||
/** A module-id pattern matching node_modules/<package>/ for the names. */
|
||||
export function packagePattern(names) {
|
||||
const escaped = names.map((name) =>
|
||||
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'),
|
||||
);
|
||||
return new RegExp(
|
||||
`[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
* PANEL_BASE next to the app.
|
||||
*/
|
||||
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
|
||||
// two in step.
|
||||
export const PANEL_BASE = '/panel/';
|
||||
@@ -18,6 +21,27 @@ export function panelBuildConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
base: PANEL_BASE,
|
||||
plugins: (config.plugins ?? []).map((plugin) =>
|
||||
plugin?.name === 'vite-plugin-cesium' ? cesium() : plugin,
|
||||
),
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: [
|
||||
// The panel runs Cesium: the Navara mode and Navara's wind mesh
|
||||
// (the panel's only paths to Navara and three) become stubs.
|
||||
{
|
||||
find: /^(?:\.\/|.*\/app\/)navaraMode\.js$|\/globe\/navara\/windFlowMesh\.js$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./panelNavara.js', import.meta.url),
|
||||
),
|
||||
},
|
||||
...(Array.isArray(config.resolve?.alias)
|
||||
? config.resolve.alias
|
||||
: Object.entries(config.resolve?.alias ?? {}).map(
|
||||
([find, replacement]) => ({ find, replacement }),
|
||||
)),
|
||||
],
|
||||
},
|
||||
build: {
|
||||
...config.build,
|
||||
outDir: PANEL_OUT_DIR,
|
||||
@@ -26,6 +50,10 @@ export function panelBuildConfig(config) {
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
// The panel is a single chunk; its alias excludes the Navara engine.
|
||||
plugins: (config.build?.rollupOptions?.plugins ?? []).filter(
|
||||
(plugin) => plugin?.name !== 'gev-initial-chunk-guard',
|
||||
),
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// The panel host transports Cesium assets; Navara workers need a web page.
|
||||
export function createNavaraScene() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
|
||||
export function navaraToolBackend() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
|
||||
export function createWindFlowMesh() {
|
||||
throw new Error('Navara is unavailable in the panel');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBrowserViteConfig } from './vite.js';
|
||||
import { panelBuildConfig } from './panel.js';
|
||||
|
||||
test('panel HTML lists Cesium assets for the host resource loader', () => {
|
||||
const config = panelBuildConfig(createBrowserViteConfig());
|
||||
const plugin = config.plugins.find(
|
||||
({ name }) => name === 'vite-plugin-cesium',
|
||||
);
|
||||
plugin.config(config, { command: 'build' });
|
||||
assert.equal(typeof plugin.transformIndexHtml, 'function');
|
||||
const tags = plugin.transformIndexHtml();
|
||||
assert.ok(tags.some(({ attrs }) => attrs.src === '/panel/cesium/Cesium.js'));
|
||||
assert.ok(
|
||||
tags.some(
|
||||
({ attrs }) => attrs.href === '/panel/cesium/Widgets/widgets.css',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('the panel replaces Navara entry points and retains browser aliases', () => {
|
||||
const browser = createBrowserViteConfig();
|
||||
const config = panelBuildConfig(browser);
|
||||
const alias = config.resolve.alias.find(({ find }) =>
|
||||
find.test('./navaraMode.js'),
|
||||
);
|
||||
assert.ok(alias.replacement.endsWith('/build/panelNavara.js'));
|
||||
// Navara's wind mesh imports three; the Cesium wind backend stays.
|
||||
assert.ok(alias.find.test('../../globe/navara/windFlowMesh.js'));
|
||||
assert.equal(alias.find.test('../../globe/cesium/windFlow.js'), false);
|
||||
assert.ok(config.resolve.alias.includes(browser.resolve.alias[0]));
|
||||
});
|
||||
|
||||
test('Three imports and requires share its ESM entry', () => {
|
||||
const { resolve } = createBrowserViteConfig();
|
||||
assert.deepEqual(resolve.dedupe, ['three']);
|
||||
const alias = resolve.alias.find(({ find }) => find.test('three'));
|
||||
assert.ok(alias.replacement.endsWith('/three/build/three.module.js'));
|
||||
assert.equal(alias.find.test('three/addons/loaders/GLTFLoader.js'), false);
|
||||
});
|
||||
+50
-2
@@ -5,6 +5,9 @@ import { VUE_PLUGIN_OPTIONS } from './vueOptions.js';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js';
|
||||
import { initialChunkGuard } from './initialChunkGuard.js';
|
||||
import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js';
|
||||
|
||||
/**
|
||||
* The repository root (this file lives at `<root>/build/vite.js`), as a
|
||||
@@ -183,7 +186,9 @@ export function createBrowserViteConfig({
|
||||
apiResponseHeadersPlugin(),
|
||||
// Next, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
// Keep the SDK asset serving/copying and external imports, but let the
|
||||
// scene load the script and stylesheet only when Cesium is requested.
|
||||
{ ...cesium(), transformIndexHtml: undefined },
|
||||
// Application views are Vue single-file components.
|
||||
vue(VUE_PLUGIN_OPTIONS),
|
||||
...plugins,
|
||||
@@ -194,6 +199,20 @@ export function createBrowserViteConfig({
|
||||
// A production build must not clean the dependency cache a running dev
|
||||
// server is still serving optimized module URLs from.
|
||||
...(command === 'build' ? { cacheDir: 'node_modules/.vite-build' } : {}),
|
||||
resolve: {
|
||||
dedupe: ['three'],
|
||||
alias: [
|
||||
{
|
||||
find: /^three$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL(
|
||||
'../node_modules/three/build/three.module.js',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
// First reached through the SDR worker or a dynamic import. Pre-bundle
|
||||
// them at startup so first use cannot invalidate already-transformed
|
||||
@@ -206,6 +225,18 @@ export function createBrowserViteConfig({
|
||||
// Loaded on demand when the first street-level image opens.
|
||||
'mapillary-js',
|
||||
],
|
||||
// Navara's wasm-bindgen glue loads new URL('x_bg.wasm', import.meta.url)
|
||||
// and its workers by URL; both must resolve next to the package's own
|
||||
// files, not inside .vite/deps. Dev server only: the experimental
|
||||
// Navara engine is a dynamic import outside the Cesium-mode bundle.
|
||||
exclude: [
|
||||
'@navaramap/three',
|
||||
'@navaramap/three-default-plugin',
|
||||
'@navaramap/engine',
|
||||
'@navaramap/engine-api',
|
||||
'@navaramap/engine-worker',
|
||||
'@navaramap/engine-font-worker',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
@@ -258,6 +289,23 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken ?? '',
|
||||
),
|
||||
},
|
||||
build: { chunkSizeWarningLimit: 1500 },
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1500,
|
||||
// Build only: the wasm Navara's workers load by name (the Navara
|
||||
// engine is a separate chunk loaded on ?engine=navara).
|
||||
// The lazy chunk with the Navara engine is named navara-engine.
|
||||
rollupOptions: {
|
||||
// initialChunkGuard: no three/Navara module in the initial chunks.
|
||||
plugins: [
|
||||
navaraWorkerAssets(),
|
||||
initialChunkGuard({
|
||||
pattern: packagePattern(
|
||||
navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))),
|
||||
),
|
||||
}),
|
||||
],
|
||||
output: { chunkFileNames: navaraChunkFileNames },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
- Add Navara, an experimental three.js/WASM globe, as a second engine next to
|
||||
Cesium, switchable live under VISUAL PRESETS → Globe Engine or with
|
||||
`?engine=navara`. Navara draws at CSS pixels like Cesium; Navara HD
|
||||
(`?engine=navara-hd`) draws at the screen's full resolution and needs about
|
||||
1 GiB more on a Retina screen. Layers keep their data across a switch and
|
||||
draw on both engines; Cesium loads only when a Cesium globe or a
|
||||
Cesium-only feature needs it (German, @gekh, #1555).
|
||||
- Keep the cockpit camera steady near the ground: it no longer jumps up and
|
||||
down when an aircraft's reported altitude sits below the globe's terrain,
|
||||
on either engine (German, @gekh, #1555).
|
||||
+92
-12
@@ -30,8 +30,10 @@ export inventory. Use declared exports rather than reaching into internal files.
|
||||
| `src/standalone/` | Select the default catalog, local sources and setup controls |
|
||||
| `src/ui/` | Navigation generations, restoration, visual state, panel snapshots and their subscriptions |
|
||||
| `src/data/` | Lifecycle, context and feed state; legacy default-layer facades |
|
||||
| `src/layers/<family>/` | Source acquisition, records and Cesium resources with explicit controller/renderer owners |
|
||||
| `src/layers/<family>/` | Source acquisition, records and shared contract displays with explicit lifecycle owners |
|
||||
| `src/sources/` | Portable protocols and source contracts; request state belongs to each factory instance |
|
||||
| `src/geo/` | Engine-neutral WGS84 maths, ported from Cesium, never imports Cesium |
|
||||
| `src/globe/` | Engine-neutral globe contract and its test support; engines only in `globe/cesium`, `globe/navara` |
|
||||
| `src/services/` | Supplied application operations; scene construction owns caches and cancellation |
|
||||
| `src/voice/` | Portable action schemas/session, common controls, action execution and separate protocol adapters |
|
||||
| `server/providers/` | Node route factories, process-scoped provider caches and shutdown cleanup |
|
||||
@@ -53,18 +55,53 @@ See [application construction](APPLICATION.md) and the
|
||||
2. `scripts/check-package-boundaries.mjs` builds every declared export without
|
||||
app Vite configuration or environment files. `scripts/package-boundaries.json`
|
||||
assigns each export exactly once and lists its owned modules and external
|
||||
dependencies. Unused imports still count. Node exports have only a `node`
|
||||
dependencies; a module entry ending in `/` (such as `src/geo/`) owns every
|
||||
file under that directory. Unused imports still count. Node exports have only a `node`
|
||||
condition; browser groups cannot use build-only dependency exceptions.
|
||||
|
||||
Portable source graphs cannot reach application/rendering, Node, Cesium or
|
||||
browser globals. This includes `sources/*`, dedicated `layers/*/source` exports,
|
||||
flight/military/vessel record and ingestion exports, action schemas, the session
|
||||
interface, lifecycle and feed state. The browser-global rule reserves platform
|
||||
names such as `document` and `window` in these modules; it is an architectural
|
||||
check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
||||
interface, lifecycle and feed state, and every runtime file in `src/geo/` (the
|
||||
engine-neutral geometry ported from Cesium). The browser-global rule reserves
|
||||
platform names such as `document` and `window` in these modules; it is an
|
||||
architectural check, not a JavaScript sandbox. The globe contract
|
||||
(`src/globe/*.js` and `src/globe/testing/**`) cannot reach `cesium`,
|
||||
`@cesium/*`, `three`, `postprocessing` or `@navaramap/*`, directly or through
|
||||
helpers, nor import an engine implementation; the `src/globe/cesium/**` graph
|
||||
reaches no Navara/three package or `src/globe/navara/**` module, and the
|
||||
`src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module,
|
||||
through helpers too. Outside its own directory, `src/globe/navara/**` is
|
||||
reached only through a literal `import()` (the app's `?engine=navara` mode), so
|
||||
Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a
|
||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||
self-package imports, symlinks and unreachable files.
|
||||
|
||||
Runtime SDK imports are restricted by directory, including unreachable modules:
|
||||
`cesium` and `@cesium/*` belong in `src/globe/cesium/`; `three`,
|
||||
`postprocessing` and `@navaramap/*` belong in `src/globe/navara/`. Package
|
||||
subpaths, re-exports and literal dynamic imports obey the same rule, and the
|
||||
`<script>` blocks of Vue components count as runtime modules. A module outside
|
||||
the Navara engine reaches it only through `import()`: a static import of the
|
||||
same specifier fails even beside a dynamic one. Tests and Node test support
|
||||
retain their existing exemptions. The production script loader is
|
||||
`src/app/loadCesium.js`; it imports no SDK package and needs no exception, and
|
||||
it adds no `widgets.css` when the page's markup has already run `Cesium.js`
|
||||
(the MCP panel). The panel build replaces `src/app/navaraMode.js` and
|
||||
`src/globe/navara/windFlowMesh.js` with stubs (`build/panelNavara.js`), so the
|
||||
panel carries no Navara or three. There is no blanket exception for layer
|
||||
`render/` directories.
|
||||
|
||||
The temporary `engineImportExceptions` list in `scripts/package-boundaries.json`
|
||||
contains exact file/package pairs with reasons. The checker reports the used
|
||||
entries and rejects duplicates, missing reasons and unused entries. Its three
|
||||
exceptions are the Bhote Koshi event, locator and embedded-media models in
|
||||
`src/data/`, which still construct Cesium property records. Their drawing uses
|
||||
shared displays. Exceptions do not relax the portable, contract or cross-engine
|
||||
graph checks. Cesium annotation renderers live in `src/globe/cesium/annotations/`;
|
||||
wind backend adapters live in their engine folders. Wind's older injected-SDK
|
||||
helpers and the remaining annotation migration are separate debt.
|
||||
|
||||
Source-only factories use dedicated `layers/<family>/source` exports where
|
||||
available, preserving the same factory implementation without loading rendering.
|
||||
Record normalization and endpoint policies have plain owners separate from
|
||||
@@ -387,10 +424,12 @@ the caller.
|
||||
## Visual effects
|
||||
|
||||
`ui/effects` exports the effects controller and existing preset definitions.
|
||||
It owns shader stages and their clock, with explicit render ownership callbacks.
|
||||
Construction installs no stages or frame callbacks. Stop animation before
|
||||
releasing UI consumers, then destroy to remove owned stages and restore the
|
||||
borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers
|
||||
It owns transitions and their clock, with explicit render ownership callbacks,
|
||||
and writes styles, bloom and sharpening through `engine.render`. Construction
|
||||
installs no stages or frame callbacks. Stop animation before releasing UI
|
||||
consumers; controller destruction disables its effects. Cesium's
|
||||
`src/globe/cesium/postStages.js` owns stage creation and destruction and restores
|
||||
the borrowed bloom state when the engine is destroyed. `ui/effects/bloom` exposes the pure intensity/version helpers
|
||||
without loading the renderer. UI presentation and product-action coordination
|
||||
remain in their callers.
|
||||
|
||||
@@ -502,7 +541,7 @@ perform no network requests and discover no additional modules.
|
||||
`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
|
||||
`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
|
||||
standard sources and their setup/fallback policy. The standalone facade wires
|
||||
these to the application's render governor.
|
||||
these to their engine's render requests.
|
||||
|
||||
A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
|
||||
Each source has a user-facing `descriptor`, availability/reason, and either an
|
||||
@@ -546,12 +585,22 @@ services into each family factory. Standalone provider selection lives in
|
||||
`src/standalone/layerSources.js`. The construction export has its own checked
|
||||
dependency graph, excluding standalone setup and compatibility layer instances.
|
||||
|
||||
`src/app/layers/renderers.js` is the layer manifest. Its lazy display loaders
|
||||
resolve the shared factories exported by `displays.js` through `display`. All
|
||||
five drawing primitives are required by the globe contract. The only display
|
||||
backend options are wind streamlines, loaded from the engine folders. A missing wind backend leaves the raster field
|
||||
available. Wildlife Sightings draws itself through the contract; Sea Marks and
|
||||
Ski Pistes use their overlay controller. No layer display imports an engine SDK.
|
||||
`displayRehearsal.test.mjs` loads every manifest display on the fake engine with
|
||||
an unknown kind and no extensions, draws a small fixture and checks cleanup.
|
||||
|
||||
Both aircraft layers share the catalog's classification registry; launches use
|
||||
its satellites and Contacts uses its aircraft, vessels and installations. Data
|
||||
registration, controls and voice actions read those same instances. Destruction
|
||||
remains the manager's responsibility; classification also observes application
|
||||
abort when startup has not reached registration. Scene engines remain page-owned,
|
||||
so this change does not introduce multiple simultaneous viewers.
|
||||
abort when startup has not reached registration. The session retains models while
|
||||
scene attachment builds displays on the active engine. A parked engine can remain
|
||||
allocated for a warm switch; its layer drawing is detached.
|
||||
|
||||
Direct `src/data` compatibility entries retain their old defaults and testing
|
||||
exports. Browser regression probes use the registered instance's testing surface
|
||||
@@ -590,6 +639,23 @@ listener cleanup. Application data assembly constructs both owners explicitly;
|
||||
`data/manager` is the compatibility facade for direct callers. The ordinary
|
||||
state subscriptions retain their existing event contract.
|
||||
|
||||
`data/layerSceneBinding.js` owns renderer attach/detach, asynchronous settlement,
|
||||
parking and failed-release retries. Lifecycle keeps visibility intentions and
|
||||
refresh policy. `app/layerSession.js` defines the retained session fields and
|
||||
handoff predicate. `standalone/sceneSwitcher.js` coordinates page handoff;
|
||||
`ui/shellScene.js` owns scene-bound shell construction, while `applicationShell`
|
||||
keeps page controls and policy. `app/sceneHost.js` owns the transition queue,
|
||||
request ordering and recovery decision. Application acquisition, attachment,
|
||||
restoration and renderer release run in that queue, and shell calls use it too.
|
||||
Nested work receives an explicit transaction; outside calls wait for the whole
|
||||
transition. A replacement application shares the transition owner with fresh
|
||||
parts, and a shell used alone creates its own host. Pick ownership, selection
|
||||
dispatch and render demand belong to
|
||||
`scene.services`; render demand drives the engine contract, with no dependency
|
||||
from the Cesium render backend to the governor. Overlay and tracked-readout
|
||||
services still include module singletons; debug access still exposes the native
|
||||
viewer and tileset.
|
||||
|
||||
## UI state owners
|
||||
|
||||
- `ui/navigation` owns navigation generations, pending search presentation and
|
||||
@@ -763,3 +829,17 @@ CSS rather than mapping machine states to universal severity. CCTV owns
|
||||
frame/source and calibration rules and their data attributes, while Weather
|
||||
owns coverage labels. The rail card keeps node identity, header focus,
|
||||
disclosure and teardown, and does not announce every card update.
|
||||
|
||||
## Engine implementation modules
|
||||
|
||||
Cesium's viewer construction, atmosphere compatibility, zoom-out guard, starry
|
||||
sky, map scene application, imagery and terrain live in `src/globe/cesium/`.
|
||||
The public viewer export still resolves through `package.json`. Celestial
|
||||
rendering, screen-event constants, scene projection and the legacy trail
|
||||
backend also live there. Sonar commands and post stages are engine internals;
|
||||
feature controllers call `engine.render`.
|
||||
|
||||
Imported-file parsing (`geoFileImport.js`, `geoImportRecords.js`,
|
||||
`geoImportKml.js`) returns plain records. `geoImportDisplay.js` consumes them
|
||||
through ground, movers, lines and imagery. The UI owns file bytes, visibility
|
||||
and attachment lifetime, with no DataSource or engine-specific import backend.
|
||||
+324
-52
@@ -151,6 +151,270 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
|
||||
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
|
||||
implementations.
|
||||
|
||||
## Navara globe engine — November 5, 2026
|
||||
|
||||
Navara, an experimental three.js/WASM globe (`@navaramap/three` 0.2.1), runs
|
||||
next to Cesium. It is picked under VISUAL PRESETS → Globe Engine or with
|
||||
`?engine=navara`; Cesium stays the default. Switching is live: the page keeps
|
||||
one application, and only the globe-bound parts move to the next engine.
|
||||
|
||||
- **Two Navara choices** (`src/app/engineChoice.js`). **Navara** draws at CSS
|
||||
pixels without MSAA, as Cesium does; SMAA still smooths edges. **Navara HD**
|
||||
(`?engine=navara-hd`) draws at the device pixel ratio with MSAA 4. Both are
|
||||
the `navara` engine to the layers; a pick between them builds the other
|
||||
variant cold and releases the first, so only one Navara holds memory.
|
||||
Measured in headless Chrome (Dubai, 1280×800): Navara 1.6 GiB at DPR 1 and
|
||||
1.8 GiB at DPR 2, Navara HD 1.8 and 2.7 GiB, Cesium 0.93 and 1.08 GiB
|
||||
(Navara used 1.95 and 3.0 GiB before).
|
||||
- **Choosing the engine.** A choice read from storage (no `?engine=`) that
|
||||
fails to start opens Cesium instead, stores Cesium in `gev.engine` and the
|
||||
address, and says so in a toast ("Globe engine failed to start. Running
|
||||
Cesium: …"). An explicit `?engine=` that fails keeps the error and the
|
||||
engine picker on the loading screen, so another choice retries without a
|
||||
reload. Back and Forward to an address without `?engine=` use the engine
|
||||
the page started with, not a `gev.engine` another tab has changed since.
|
||||
A switch that fails and leaves the previous engine running names that
|
||||
engine in its toast. The Globe Engine picker is a compact field that keeps
|
||||
keyboard focus while a switch runs (`aria-disabled`, `aria-busy`); a
|
||||
hidden status region announces "Initializing systems..." and the new
|
||||
engine.
|
||||
- **Memory.** The canvas context has no antialias (Navara draws into its own
|
||||
targets and copies the result), the cloud shadow maps use the low preset's
|
||||
256² and 2 cascades, and the tile cache budget is the screen-sized targets
|
||||
plus 768 MiB instead of Navara's default of up to 2 GiB, which a long session
|
||||
filled. A released Navara keeps no JS: catalog closures capture the
|
||||
operations, not the scene; a released globe aborts with a reason made at
|
||||
module load (a fresh one keeps the stack, and with it the scene); and
|
||||
`Pass.fullscreenGeometry` from postprocessing is disposed after the view,
|
||||
which otherwise pins the canvas through a three listener.
|
||||
|
||||
- **The engine contract** is `src/globe/contract.js`; its drawing primitives
|
||||
(movers, lines, ground, imagery, meshes), scene services and how to add an
|
||||
engine are in [Globe primitives](GLOBE-PRIMITIVES.md). `src/globe/cesium/*`
|
||||
and `src/globe/navara/*` implement it, `src/globe/testing/fakeEngine.js`
|
||||
stands in for tests, and `src/globe/testing/contractSuite.mjs` holds the
|
||||
checks every engine must pass. `npm test` runs them on the fake engine;
|
||||
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
|
||||
on the real Cesium and Navara of a keyless dev server, with a real pointer
|
||||
and real canvases (`--only=ground:` and `--only=imagery:` check ground and
|
||||
imagery pixels); checks of state the app drives itself (contact sonar) are
|
||||
skipped and named. Among its members: `surface.sampleTerrainMostDetailed`
|
||||
(terrain heights at the finest level, fetched where not loaded),
|
||||
`maps.terrainRevision()` (changes when the terrain shown is replaced),
|
||||
`render.maxTextureSize()` and `MoverSet.failed()` (a model or atlas that
|
||||
will not load). Navara also has Cesium's `contactSonarDiagnostics()`,
|
||||
outside the contract.
|
||||
- **Navara's ground and imagery passes.** Navara draws ground fills and
|
||||
corridors in one screen pass classified on the scene depth
|
||||
(`src/globe/navara/ground/`): a corridor set is tiles of binned segments
|
||||
(`corridors.js`), and 10,000 corridors draw in about 12 ms still and 14 ms
|
||||
moving, where Cesium takes 23 ms (the chunks before took 151 and 196 ms).
|
||||
Imagery layers draw in one screen pass after tone mapping, classified on
|
||||
the scene depth (`src/globe/navara/imagery/`); the Active Fires heatmap is
|
||||
mipmapped at orbit scale. Sea Marks and Ski Pistes keep Navara's native
|
||||
raster path for tile detail and use `engine.imagery` on engines without
|
||||
`maps.addOverlay`.
|
||||
- **Ground and input parity.** Space Missions draws its 500 m launch-pad
|
||||
zone with the radial ground material: faint fill, bright soft rim and
|
||||
emission, classified on terrain and tiles. Transit draws its age-banded
|
||||
trail and dark backing as ground corridors. The active segment uses a
|
||||
shader reveal, retaining its geometry between segment crossings. Navara
|
||||
composes both in the ground pass after tone mapping. Street Level's 9 px
|
||||
pick window and CCTV's custom window and drill limit go through input
|
||||
subscriptions. Street Level's lowered horizon ellipsoid is a projector
|
||||
option on every engine; default callers retain the WGS84 horizon.
|
||||
- **Shared follow and Draw.** Share links read the follow target, identity and
|
||||
camera pose through the engine; their saved offset uses `camera.follow`'s
|
||||
`swingTo` option on both engines. Cesium keeps its guarded 60-degree frame
|
||||
swing. Draw uses one contract surface with lines, point movers and an
|
||||
exclusive `engine.input` pointer claim. Cesium's stock select/track actions
|
||||
are suspended by the engine while drawing and restored on release. Scene
|
||||
detach ends the sketch and removes the preview, retaining shape and colour.
|
||||
- **Engine-owned effects and camera input.** Cesium's viewer, atmosphere
|
||||
compatibility, zoom guard, starry sky and map implementation live in
|
||||
`src/globe/cesium/`. Styles, bloom and contact sonar use `engine.render`.
|
||||
Camera input suspension is counted on every engine; the last resume restores
|
||||
the state before the first suspension. Cockpit, imagery-box drawing, CCTV
|
||||
calibration and infrastructure flights use that shared ownership API.
|
||||
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
|
||||
engine-specific code outside the engines: renderer lines per engine, Cesium
|
||||
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
|
||||
ground passes, Cesium SDK loads and `ScreenSpaceEventHandler` users.
|
||||
`scripts/engineCoupling.test.mjs` (in `npm test`) fails when a count grows
|
||||
past `scripts/engine-coupling-baseline.json`.
|
||||
- **Display rehearsal.** `src/app/layers/displayRehearsal.test.mjs` loads all
|
||||
35 display entries from the manifest on a fake engine with an unknown kind
|
||||
and no native extensions, draws fixtures, hides or releases them, and checks
|
||||
primitives, subscriptions and service records before engine destruction.
|
||||
The three self-drawing entries are named skips. This is a display-level
|
||||
rehearsal; application kind registration and native debug access remain
|
||||
work.
|
||||
- **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
|
||||
requests, application attachment and direct shell calls. One transaction
|
||||
covers handoff, renderer release, candidate acquisition, attachment, view
|
||||
restoration and the single recovery attempt. Nested parts receive that
|
||||
transaction explicitly; unrelated calls wait for it. Picker/history ordering,
|
||||
hidden-tab settling and parked-renderer reuse retain their existing behavior.
|
||||
A replacement application registers fresh parts on the same transition owner;
|
||||
a shell used alone creates its own scene host.
|
||||
- **Scene services per attachment.** Each attachment owns its picking,
|
||||
selection, render demand, world overlay, tracked readout and context
|
||||
services (`src/data/sceneServices.js`; described under Input ownership,
|
||||
Render demand and World overlays in [Globe primitives](GLOBE-PRIMITIVES.md)).
|
||||
On a switch, detach releases them before the renderer is parked and a
|
||||
reattachment, parked engines included, creates fresh ones; retained layers
|
||||
register their picks again and resolve their attached engine, an old
|
||||
service cannot release a replacement's render hold, and destroyed services
|
||||
stay inert. The shell reconnects its overlay action subscription; shared
|
||||
font measurements live until the last overlay host detaches. Engine handoff
|
||||
carries the retained page selection into the incoming context, and readout
|
||||
initialization leaves it intact while the outgoing display is hidden and
|
||||
the incoming layer waits for camera restoration. Label text, live visual
|
||||
anchors and follow-camera anti-jitter are unchanged.
|
||||
- **The session owns the state.** Catalog, layer models, sources, selection,
|
||||
camera mode and URL state outlive a switch. A picker switch hands the
|
||||
outgoing camera's exact pose to the incoming one (the address rounds it to
|
||||
1e-4°, whole metres and whole degrees); a history visit takes the entry's
|
||||
view. A layer that is a session model
|
||||
gets `attachRenderer(viewer, { engine })` / `detachRenderer()` instead of being rebuilt,
|
||||
so its data is not fetched again. The outgoing globe is parked
|
||||
(`src/standalone/rendererCache.js`), so switching back within three minutes
|
||||
is warm (60-90 ms). After that the parked globe is disposed and its WebGL
|
||||
context lost, which frees about 1.1 GiB for a parked Navara; switching back
|
||||
then builds it cold (1.5-2 s). `?parkedMs=` shortens the delay for QA
|
||||
(`scripts/qa-strict-parked-release.mjs`).
|
||||
- **Navara camera and drawing parity.** A render requested during a frame
|
||||
draws the next frame, so the wind animates under a still camera. A flight
|
||||
turns camera input off until it ends; a gesture no longer interrupts it.
|
||||
`setView` cancels a running flight on both engines. Heading and roll
|
||||
changes raise `camera.changed`, as on Cesium. 3D models take the Cyber
|
||||
sonar, which draws at its cadence only while contacts are on screen. Ground
|
||||
items are picked at the shown 3D tiles under Google 3D, else at the
|
||||
terrain, by a CPU ray (Navara's depth read answers with the near plane
|
||||
right after a click). Ground corridor and outline widths are CSS pixels at any device
|
||||
pixel ratio (Navara HD). Tiled imagery draws across the antimeridian and
|
||||
replans its tiles on a resize. A follow takes a two-finger pinch. Navara
|
||||
does not yet refresh an ion terrain token on a 401, nor fall back to
|
||||
OpenStreetMap when base imagery tiles fail after the source was built
|
||||
(Cesium does both); its package exposes no hook for either.
|
||||
- **Layer heights and recovery after a switch.** S2 GhostMaps icons are
|
||||
sprite movers without depth test that stand on the surface: the terrain on
|
||||
the globe, a sample of the 3D tiles under Google 3D. Only icons on screen
|
||||
are measured, at most 16 and 4 ms a pass, again after the camera moves and
|
||||
tiles load; a height that holds is not written again, so a parked camera
|
||||
asks for no frames. Submarine cable landing markers stand on the surface by
|
||||
the same rule and draw over the scene within 2.5 km of the camera. OSM Places refines heights only for places on
|
||||
screen, within a per-pass budget, after camera moves and tile loads, and
|
||||
drops implausible samples. Directions' A/B markers and Street Level's cones
|
||||
move again once tiles load, under Google 3D too. Traffic's road heights are
|
||||
sampled again when the terrain changes (`maps.terrainRevision`), and cars
|
||||
on roads below the ellipsoid are not culled by the WGS84 horizon. The
|
||||
weather shell halves its images (to 1024 px wide) when the GPU's texture
|
||||
limit is under 4096 (`render.maxTextureSize`). A CCTV layer that was off
|
||||
stays hidden after a switch, and the active camera's obstruction probe runs
|
||||
again once the new engine's surface is ready. A tracked aircraft's glTF
|
||||
that the engine did not load is loaded again in a new mover set after a
|
||||
backoff and given up after a failure budget; far contacts in Cockpit draw
|
||||
as dots from their own sprite set.
|
||||
- **Layer state and cards after a switch.** A history visit across engines
|
||||
applies the visited entry's layer options that differ from the live
|
||||
layer (Wind's model, say); equal options are left alone, so a plain switch
|
||||
writes no layer parameters. The Bhote Koshi evidence cards are published
|
||||
to the incoming engine's overlay. On Navara their anchors stand on the
|
||||
terrain sampled at its finest level; where that fails they start at the
|
||||
pack's elevations and follow the terrain loaded under a camera within
|
||||
25 km. A followed aircraft's card anchors to the model drawn
|
||||
(`gevVisualPosition`), on both engines. Turning a layer off ends its camera
|
||||
arrival correction on every engine. North-up and tilt during a follow work
|
||||
on both engines and keep the follow: Cesium sets the frame through
|
||||
`lookAtTransform` in its EntityView frame, Navara through the follow's
|
||||
`retarget`.
|
||||
- **Shared layer displays.** Every manifest display draws through the globe
|
||||
contract from `render/display.js`; the aircraft family shares its display
|
||||
implementation. There are 35 display entries (including the three weather
|
||||
products and two infrastructure datasets). Wildlife Sightings draws through
|
||||
the contract inside its layer; Sea Marks and Ski Pistes use their overlay
|
||||
controller. Wind streamlines retain backend adapters in
|
||||
`src/globe/cesium/windFlow.js` and `src/globe/navara/windFlow.js`;
|
||||
the fills of the ground layers, installation outlines and the selection fill
|
||||
are `engine.ground` sets. Global Context's range pulses use
|
||||
`engine.ground.createPulseField`: the same fronts, halos, inward bands,
|
||||
wake/rest timing and raised ellipsoid on every engine. Cesium retains its
|
||||
material disc; Navara's shader is owned by `src/globe/navara/ground` and
|
||||
draws after tone mapping. Bhote Koshi's event and locator share
|
||||
`src/layers/bhoteKoshi/render/story.js`, using ground polygons and corridors
|
||||
with Cesium's dashed halo and glowing core, plus point movers with animated
|
||||
outline opacity. Their authored property records remain in the story model.
|
||||
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
|
||||
Imagery draws its days through `engine.imagery` from its own
|
||||
`render/display.js`. CCTV's monitor plane and viewshed volume use
|
||||
`engine.meshes` through `src/layers/cctv/render/meshes.js`, with no layer
|
||||
backend per engine. Data packs share `src/scenes/dataPacks/display.js`:
|
||||
movers, lines and world meshes retain authored heights, polygon holes,
|
||||
images and picking across scene detach and reattach. Annotation drapes
|
||||
remain on their existing renderers pending bounded classification support;
|
||||
the Cesium implementations live in `src/globe/cesium/annotations/`.
|
||||
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
|
||||
says how each directory layer draws: its shared `display` (with an optional
|
||||
engine-specific `backends` option keyed by engine kind: wind streamlines), or
|
||||
a layer that draws itself through the contract (Wildlife Sightings). Every
|
||||
engine implements all five drawing primitives, so the manifest lists no
|
||||
primitives a display needs and registers no supported engine kinds; Sea
|
||||
Marks and Ski Pistes drape themselves through their overlay controller.
|
||||
Every layer shows on both engines. The displays and wind backend load
|
||||
through `loadLayerRenderers(kind)`, awaited before the engine's catalog.
|
||||
`rendererFor(id, engine, options)` answers synchronously after that.
|
||||
- **One tools tree.** `src/app/tools.js` attaches the same tools to every
|
||||
engine (`attachEngineTools`): the annotation board, Draw, Import, the
|
||||
Recent Imagery box and panel, the render governor, the scope mask,
|
||||
hidden-tab handling and voice. Each engine kind's tool backend says where
|
||||
they draw; Cesium's also holds the tools that still drive its viewer
|
||||
(release trim, tracked-entity hold, the render-loop suspension, the
|
||||
embedded views), which read it through `engine.extensions.cesium?.viewer`
|
||||
and so stay absent on Navara. Navara's backend (`navaraToolBackend` in
|
||||
`src/app/navaraMode.js`, loaded with its mode) adds the daylight of a
|
||||
restored view and the engine notice.
|
||||
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
|
||||
when a Cesium globe starts or a Navara user turns on a feature that still
|
||||
uses Cesium's maths or entity model (the Bhote Koshi event and locator;
|
||||
`src/app/loadCesium.js`). Imported files use plain records and load no SDK. App code does its maths with `src/geo`, a Cesium-free
|
||||
port of the vector, ellipsoid, transform and time functions that
|
||||
`bitExact.test.mjs` compares with Cesium bit for bit.
|
||||
**Invariant: no module reads Cesium while it loads**
|
||||
(`scripts/check-cesium-load-time.mjs`, run by `npm test`).
|
||||
- **Boundaries.** The import-direction gate keeps Cesium packages in
|
||||
`src/globe/cesium/` and three, postprocessing and Navara packages in
|
||||
`src/globe/navara/`, which loads only through `import()`; the rules are in
|
||||
[Code boundaries](CODE-BOUNDARIES.md). The measured temporary exceptions in
|
||||
`scripts/package-boundaries.json` are the three Bhote Koshi model modules
|
||||
(event, locator and embedded media still build Cesium property records),
|
||||
and the gate rejects unused exceptions. `build/initialChunkGuard.js` fails
|
||||
the build when one of them reaches an initial chunk, and
|
||||
`npm run check:main-chunk` holds the main chunk to its gzip budget
|
||||
(`scripts/main-chunk-baseline.json`). Only `src/globe/navara/internals.js`
|
||||
touches Navara's private members.
|
||||
- **Navara 0.2.1 workaround.** The SDK keeps feature-change events of deleted
|
||||
vector layers in its queue, so the view never reaches idle. They are dropped
|
||||
each frame (`src/globe/navara/featureQueue.js`).
|
||||
- **Strict acceptance.** `node scripts/qa-all-layer-switches.mjs <origin> <out>
|
||||
--serve` builds the commit keyless in a clean worktree and runs every layer
|
||||
and switch scenario under a deny-by-default network policy, with free map
|
||||
tiles from a disk cache and a trap that fails any read of Cesium before it
|
||||
has loaded. Every catalog layer must be asserted on both engines. Point
|
||||
`PUPPETEER_EXECUTABLE_PATH` at an installed Chrome if Puppeteer's pinned
|
||||
build is missing. Navara lights the globe by the real sun; every base map,
|
||||
street maps included, takes the night fill after dusk, so a scenario over
|
||||
Austin or San Francisco at local night still sees a legible map, as on
|
||||
Cesium (`src/globe/navara/look.js`).
|
||||
- **Navara 0.2.1 workaround.** The SDK never frees pick batch ids, and the
|
||||
mover banks rebuild their instanced meshes on each attach, so every switch
|
||||
leaked one id per instance of capacity (about 260 KiB of WebAssembly memory).
|
||||
A per-view pool hands released ids back out
|
||||
(`src/globe/navara/movers/batchIds.js`); the long soak asserts both JS
|
||||
objects and native memory stay flat.
|
||||
- **Ground visibility.** Polygon displays show and hide engine ground sets
|
||||
through the screen pass on Navara.
|
||||
|
||||
## Wildlife Sightings — iNaturalist and Acartia
|
||||
|
||||
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
|
||||
@@ -189,17 +453,25 @@ snapshot, including one whose rows all fall outside coverage or the species
|
||||
checks, is a fresh success that clears earlier rows; only a malformed or failed
|
||||
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
|
||||
recognized cetaceans; other identified marine mammals appear under ALL or
|
||||
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
|
||||
positions are rounded to 0.01° on the server. Acartia comments are never retained.
|
||||
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells, drawn as
|
||||
taxon-accent fills at 25% opacity with 2 px outlines at 80% opacity through
|
||||
`engine.ground`, draped on terrain and tiles. Cells are not pickable and
|
||||
identical bounds and accents share one item. Acartia positions are rounded to
|
||||
0.01° on the server. Acartia comments are never retained.
|
||||
Observer identity is retained only for photo attribution.
|
||||
|
||||
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
|
||||
The layer draws on both engines through one display
|
||||
(`src/layers/wildlifeSightings/display.js`): taxon badges and the density glow
|
||||
are `engine.movers` sprite sets.
|
||||
|
||||
`/api/inaturalist/grid` supplies a dedicated, nonpickable sprite density glow,
|
||||
with a view-relative density ramp from cool teal to warm amber. Overlapping
|
||||
Gaussian splats span four logical cell widths; mono and CRT styles follow map
|
||||
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
|
||||
stay inside the globe limb with a 5% radius margin; opacity fades from full at
|
||||
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
|
||||
add no per-frame work and leave sighting counts intact.
|
||||
3,000 km to zero at 8,000 km camera altitude. Splats are sized in metres on
|
||||
the CPU, only on frames where the camera moved; culling and cached coordinates
|
||||
leave sighting counts intact.
|
||||
|
||||
Only real sampled observation rows produce taxon badges at their public
|
||||
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
|
||||
@@ -249,7 +521,6 @@ Disable/destroy releases the glow collection, texture atlas, timers, queues and
|
||||
style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -352,7 +623,10 @@ style, map, marks and an aircraft or satellite to follow, written in the
|
||||
share-link format (`gods-eye-view/view`). `show_in_gods_eye_view` shows a view as live
|
||||
God's Eye View inside clients that display MCP Apps, and as a link everywhere.
|
||||
The panel runs the app's panel build (`npm run build:panel`, served at
|
||||
`/panel/`) and loads it, with its data, through the MCP server, so it works
|
||||
`/panel/`), a Cesium-only build: its HTML loads `Cesium.js` and
|
||||
`widgets.css`, and stubs keep Navara and three out of its scripts
|
||||
(`build/panelNavara.js`); its size after that change has not been measured.
|
||||
The MCP server loads it, with its data, so it works
|
||||
in Claude Desktop and in the Codex and ChatGPT desktop apps with a local
|
||||
server and no HTTPS. `?embed=1` shows the app as the globe alone and takes
|
||||
new views from the page that frames it; framing is off unless
|
||||
@@ -736,7 +1010,7 @@ forecast lead-hour points and cones render only when all source parts match the
|
||||
status advisory; a newer status shows its position with geometry pending rather
|
||||
than relabeling older geometry. The cone represents forecast center uncertainty,
|
||||
not storm size or the full hazard area. Successful empty and unavailable states
|
||||
are distinct. One owned native Cesium data source preserves polygon holes and
|
||||
are distinct. The display draws centres and forecast points as point movers and each storm's track and cone as ground sets, keeping polygon holes and
|
||||
geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
@@ -757,7 +1031,7 @@ Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches
|
||||
`terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no
|
||||
proxy in this repository sees those requests (`/api/terrain/heights` is the
|
||||
separate point-height endpoint), and the upstream edge answers zoom-out bursts
|
||||
with HTTP 429 under load. `src/maps/terrain.js` hands Cesium a `Resource`
|
||||
with HTTP 429 under load. `src/globe/cesium/terrain.js` hands Cesium a `Resource`
|
||||
whose retry policy (`src/maps/terrainRetry.js`) every derived tile fetch
|
||||
inherits: 429, 502, 503 and 504 replies retry up to three times, 750 ms
|
||||
doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
||||
@@ -1222,13 +1496,14 @@ screen. Cyan camera badges use Manjunath's camera artwork; selection switches to
|
||||
larger coral badge with corner brackets and an animated tactical card. Numeric
|
||||
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
|
||||
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
|
||||
selected camera; farther cameras keep native Cesium badges without wedges.
|
||||
selected camera; farther cameras keep native badges without wedges.
|
||||
Native ground-clamped wedges (a ground polyline plus a classification polygon per
|
||||
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
|
||||
and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
|
||||
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a
|
||||
tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
||||
badge whose cell has not resolved sits on the terrain (hidden while none is
|
||||
known) until it does, then moves in place.
|
||||
Overlay anchors start on the same floor and write marker properties only on
|
||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
@@ -1707,7 +1982,7 @@ provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
|
||||
|
||||
## Sky background
|
||||
|
||||
`src/sky/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
|
||||
`src/globe/cesium/starrySky.js` fills Cesium's `Scene#skyBox` slot with 9,096 Yale
|
||||
Bright Star Catalogue stars and a soft ESO/S. Brunier Milky Way. Both layers
|
||||
rotate from ICRF/J2000 into the Earth-fixed frame at the scene clock, with
|
||||
a TEME fallback while Cesium's IAU 2006 tables load. Bundled assets and
|
||||
@@ -1753,11 +2028,15 @@ remain unchanged.
|
||||
|
||||
## Visual effects ownership
|
||||
|
||||
VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom
|
||||
and sharpen. Display supplies actions and renders settings; effect execution has
|
||||
no DOM dependency. Existing presets and the 500 ms transition remain unchanged.
|
||||
Stopping the controller revokes animation before asynchronous UI teardown; final
|
||||
destruction removes its stages and restores the borrowed bloom configuration.
|
||||
VisualEffects owns crossfades and animation scheduling and drives styles, bloom
|
||||
and sharpening through `engine.render` on both engines. Cesium's post stages,
|
||||
including the Anime Milky Way fade, live in `src/globe/cesium/postStages.js`;
|
||||
its sonar lives in `sonar.js` and `sonarCommands.js`. Display supplies actions
|
||||
and renders settings; effect execution has no DOM dependency. Existing presets
|
||||
and the 500 ms transition remain unchanged. Stopping the controller revokes
|
||||
animation before asynchronous UI teardown; controller destruction disables the
|
||||
effects. Engine destruction removes stages and restores borrowed bloom.
|
||||
Cesium advertises visualStyles, bloom and contactSonar, but not cloudCover.
|
||||
Snow is no longer a visual preset. A share link that names it, in either the
|
||||
long or compact form, opens on Normal and drops the Snow shader parameters. A
|
||||
view passed to embed mode or `show_in_gods_eye_view`, and a saved scene shot,
|
||||
@@ -2364,7 +2643,7 @@ retaining rooftop and below-sea-level elevations. A hidden globe does not
|
||||
constrain photoreal geometry, and retry work remains bounded. Already sampled
|
||||
nearby markers follow higher settled terrain on existing bounded walks, without
|
||||
additional GPU samples or timers, when close-up tile detail refines the floor.
|
||||
The submarine-cable renderer is unchanged. These limits bound active stem work;
|
||||
Submarine cables draw through `render/display.js` on every engine. These limits bound active stem work;
|
||||
they do not reduce the materialized entity count or establish an FPS gain.
|
||||
|
||||
## Keyboard interaction and focus
|
||||
@@ -2509,7 +2788,7 @@ same hues, from a bright red edge through orange and gold to a pale
|
||||
white-hot core. The edge starts more than half opaque, so regions far below
|
||||
the hottest ones still read over forest and savanna imagery. Nothing is painted where
|
||||
nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery
|
||||
tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt
|
||||
tile on the active imagery host (`src/layers/firms/render/display.js`), rebuilt
|
||||
on each data refresh or filter change (the first far-band paint at once,
|
||||
later ones 150 ms after the last change). Heatmap and markers are both kept
|
||||
built at every zoom and cross-fade by camera height between 3,600 and
|
||||
@@ -4835,22 +5114,14 @@ zero-duration journey or immediately relocate a visible vehicle.
|
||||
**Selected history.** Selecting one vehicle draws its available recent path,
|
||||
clipped to the displayed time. The browser retains up to 15 minutes and 128
|
||||
fixes per vehicle, under a global 32 MiB history budget. Capacity pressure may
|
||||
shorten that history. Only the selected vehicle has a trail: completed segments
|
||||
are batched primitives, and a small moving head ends at the marker. Trail
|
||||
geometry is bounded to 640 prepared vertices, prioritizing the active corridor
|
||||
and shortening the oldest geometry first. Active and future marker corridors
|
||||
retain their own 640-vertex budget; long report intervals use coarser subdivisions
|
||||
than 25 m instead of dropping an admitted vehicle. Supported scenes use ground polylines
|
||||
classified against both terrain and 3D tiles, including after map-stack changes,
|
||||
with per-segment age colours. History changes rebuild the body. The head is one
|
||||
short draped corridor, clipped to displayed time by a material uniform; only
|
||||
subdivision crossings rebuild it. Ordinary frames upload no head geometry.
|
||||
Without ground-polyline support, ordinary primitives retain a mode-coloured
|
||||
0.55-alpha depth-fail path, including the head, without dimming it to near-black.
|
||||
Instance attribute updates write back the same typed array returned by Cesium;
|
||||
its getters return copies, so reading a second time discards a visibility or colour edit.
|
||||
Normal instance colours follow the age ramp once per display second or style change. Subdivision crossings update only changed visibility
|
||||
attributes. Deselecting releases selection geometry; marker paths retain only
|
||||
shorten that history. Only the selected vehicle has a trail, drawn as
|
||||
`engine.ground` corridors draped on terrain and 3D tiles on every engine:
|
||||
completed segments draw in two lanes (a 5 px dark edge under a 3 px mode
|
||||
colour), one corridor per lane for each run of joined segments in one of 29
|
||||
age bands, re-banded once per displayed second or style change. The active
|
||||
segment is one corridor with its dark backing; its geometry is written once
|
||||
per segment and a shader reveal (`setReveal`) draws it up to the marker, so it
|
||||
is not rewritten while the marker moves. Trail geometry is bounded to 640 prepared vertices, prioritizing the active corridor and shortening the oldest geometry first. Active and future marker corridors retain their own 640-vertex budget; long report intervals use coarser subdivisions than 25 m instead of dropping an admitted vehicle. Deselecting releases selection geometry; marker paths retain only
|
||||
active and future corridors.
|
||||
|
||||
MBTA history is also retained in the running proxy: up to 15 minutes and 128
|
||||
@@ -4970,7 +5241,7 @@ heap growth ≤24/48 MiB. The 3,000-vehicle CPU budget is 4 ms (recorded browser
|
||||
measurement 3.4 ms); the 800-vehicle budget stays at 1 ms (measured 0.9 ms).
|
||||
Transit-authored frame allocation is ≤8 KiB/frame at both fleets, attributed
|
||||
by source stack to `src/layers/transit/**`, `src/data/contactPlayback.js`,
|
||||
`src/data/contactTrailRenderer.js` and `src/data/transit*.js`. Whole-frame
|
||||
`src/data/transit*.js`. Whole-frame
|
||||
allocation and its three largest allocators are reported without gating:
|
||||
Cesium billboard buffer uploads and the shared DETECT painter are outside
|
||||
this layer's allocation budget. **Follow-up: pool shared DETECT placement and
|
||||
@@ -5052,17 +5323,17 @@ height still choose their own unit.
|
||||
At global scale, ambient Radio cluster badges are hard-opacity shared-host
|
||||
entries: count/category updates and identity replacement do not run keyhole or
|
||||
enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon
|
||||
culling can still remove an invalid placement, and Cesium still owns the cluster
|
||||
point geometry and picks. Their 50,000 km line-of-sight range covers the
|
||||
culling can still remove an invalid placement, and the radio display's cluster
|
||||
points (screen clusters with Cesium's EntityCluster rules) carry the picks. Their 50,000 km line-of-sight range covers the
|
||||
supported full-globe camera above 24,000 km, including farther horizon clusters.
|
||||
Unclustered visible stations publish nearest-first ambient labels through that
|
||||
same host, capped at 16 labels globally, 32 at intermediate zoom, and 48 nearby;
|
||||
cluster and singleton candidates still share the Radio source's 64-entry ambient
|
||||
cohort. Cesium continues to own each 13 px station point, horizon visibility,
|
||||
and direct/nearby picking. Native points and shared-host text use one 50,000 km
|
||||
cohort. The display draws each 13 px station point, horizon visibility,
|
||||
and direct/nearby picking. Points and shared-host text use one 50,000 km
|
||||
interaction limit, so a painted singleton or cluster label always retains a
|
||||
pickable point at its anchor throughout the supported high-global view. No
|
||||
Radio entity uses native Cesium label text.
|
||||
Radio point uses engine label text.
|
||||
|
||||
Delivery constraint: PR #10 is stack-only and unsafe standalone. PR #11 owns
|
||||
the required Radio lifecycle/authority repair, so PR #10 must not merge or ship
|
||||
@@ -5489,7 +5760,7 @@ silently demoting every later lookup for the session.
|
||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch zone, an `engine.ground` disc with the radial material (a faint fill, a bright soft rim and emission) draped on terrain and 3D tiles. The zone is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
@@ -5753,7 +6024,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
||||
> `main` + 26 route pins; the same tree on Node 24 reports 2,295.
|
||||
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
|
||||
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per frame of the globe engine (Cesium or Navara) through the globe contract. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck currentLine truncated
|
||||
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
|
||||
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
|
||||
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
|
||||
@@ -6037,14 +6308,15 @@ request viewbox and continues searching without that geographic preference.
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- UTILITIES ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. `geoFileImport.js` and `geoImportKml.js` produce plain records; one `geoImportDisplay.js` draws them on ground, movers, lines and imagery on every engine, without loading Cesium. Ground paths follow great circles and retain segment gaps. Pins use square sprite canvases preserving Cesium's icon placement and styles; a file's icon looks share a budget of 16 million canvas pixels, so many distinct styles draw at the same size from lower-resolution textures. A KML heading turns with the camera's heading, staying measured from north. Ground and relative pins sample loaded terrain and, at camera heights below 250 km with a tile surface, `engine.surface.clampToHeight` for the pins in the window; absolute pins keep their height. The first sampling and every refresh run in batches of 256 that yield between them; refreshes go through the controller's serial queue after camera/map changes and terrain completion, retaining the last available height on misses. Ground pins ignore scene depth and are horizon-culled. Removal destroys every owned drawing set.
|
||||
- **Drawing limits.** Raised polygons draw their fills (meshes) and outlines at their vertices' heights. Ground overlays take their colour's tint and alpha; a rotated one is turned on a canvas over the box around it, and an absolute one is raised to its altitude, as Cesium does (a relative one drapes, as on Cesium). Wall fills, extruded prisms, `gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised plain-colour overlay are counted as undrawn; wall top edges and polygon top geometry remain. Rhumb edges become great circles. Packed image overlays draw on both engines. A GeoJSON line of one point or a polygon of fewer than three distinct corners is not a record, so a file of only those is refused as having nothing to draw.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rectangular overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
||||
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas.
|
||||
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
||||
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
||||
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
||||
- **Input handling.** Coordinates and GeoJSON geometry types are checked before drawing allocation. Only short plain names reach the record model; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
||||
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and points with the same icon look share a sprite set and atlas canvas.
|
||||
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 drawable records (including multipart children); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
||||
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with the scene engine, navigation, ground services and map-source subscription. Detach destroys drawings and revokes packed-image URLs; reattach reparses the retained file bytes and restores visibility. Destroy aborts pending work, unsubscribes, removes drawings, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
||||
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, effective style equivalence against a native Cesium control, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
||||
|
||||
### Recent Imagery (September 2026)
|
||||
|
||||
@@ -6059,7 +6331,7 @@ pins and share links already supply complete boxes.
|
||||
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
|
||||
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
|
||||
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
||||
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
||||
- **Rendering.** `render/display.js` owns at most two GIBS layers on the surface reported by `engine.imagery.surface()`: the globe, a shown tileset, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
||||
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
||||
- **Panel.** `#recent-imagery-panel` is rail markup in the Context view (`src/ui/views/ContextView.vue`) beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
||||
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
||||
@@ -6618,7 +6890,7 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
counters, and timing labels.
|
||||
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
|
||||
|
||||
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone ground primitive and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
||||
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch zone (a radial ground disc draped on the scene) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
||||
|
||||
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ load, removes partial results and reports a stable error without exposing a URL.
|
||||
Successful loading leaves the attribution cards visible with the assets.
|
||||
|
||||
`createApplicationTools({ sceneDataPacks: { sources } })` and
|
||||
`new SceneDirector(viewer, style, data, { dataPacks: { sources } })` accept a
|
||||
`new SceneDirector(scene, style, data, { dataPacks: { sources } })` accept a
|
||||
registry of source functions. Each receives `{ path, signal, maxBytes }` and
|
||||
returns `{ bytes: Uint8Array, mimeType }`. Custom transports must honor the
|
||||
signal, bound reads and return the real MIME type. They remain application code,
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
# Globe primitives
|
||||
|
||||
A layer draws through the globe-engine contract (`src/globe/contract.js`),
|
||||
not through Cesium or Navara. The contract has five drawing primitives:
|
||||
|
||||
- `engine.movers`: things at points;
|
||||
- `engine.lines`: lines in the air;
|
||||
- `engine.ground`: fills on the ground;
|
||||
- `engine.imagery`: rasters on the ground;
|
||||
- `engine.meshes`: textured or coloured world triangles.
|
||||
|
||||
Each engine implements all five. Cesium's are in `src/globe/cesium/`,
|
||||
Navara's in `src/globe/navara/`, and the fake engine's in
|
||||
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
|
||||
`src/globe/testing/contractSuite.mjs`.
|
||||
|
||||
## Movers: things at a point
|
||||
|
||||
`engine.movers.createSet({ id, kind, capacity, ... })` makes a set of up to
|
||||
`capacity` instances drawn as one batch. Write them with
|
||||
`set(index, fields)` and `setCount(n)`, then upload the changes with one
|
||||
`commit()`.
|
||||
|
||||
There are three kinds:
|
||||
|
||||
- `sprite`: an image from an atlas, a square of `sizePx`, turned by
|
||||
`rotation`;
|
||||
- `point`: a filled disc with an outline;
|
||||
- `model`: a glTF in metres, at least `minimumPixelSize` on screen.
|
||||
|
||||
Use movers for aircraft, vessels, satellites, cameras, stations: anything
|
||||
that has a position and is drawn the same size on screen (or as a model).
|
||||
|
||||
Point instances may also set `outlineColor` independently of the fill,
|
||||
so an expanding callout can fade its outline without changing its width.
|
||||
|
||||
Instances move every frame without a rebuild. `depthTest` decides whether
|
||||
the scene hides them. A pick returns the instance's `pickId` when the set is
|
||||
`pickable`. `ready(index?)` says whether a model's glTF or a sprite atlas has
|
||||
loaded and `failed(index?)` whether it failed to: a failed set is not loaded
|
||||
again, so a layer that retries makes a new set.
|
||||
|
||||
## Lines: polylines in the air
|
||||
|
||||
`engine.lines.createLine({ id, widthPx, color, ... })` makes one polyline.
|
||||
You can change its positions, colour and model matrix at any time.
|
||||
|
||||
The width is in CSS pixels. The line follows the 3D positions it is given;
|
||||
it does not follow the ground. Options:
|
||||
|
||||
- `arc: 'geodesic'` bends it along great circles;
|
||||
- `dash` draws dashes;
|
||||
- `depthFailAlpha` draws a dimmer copy where the scene hides it.
|
||||
|
||||
Use lines for trails, orbits, launch paths, a ring at a fixed height, and
|
||||
leader lines.
|
||||
|
||||
## Ground: fills on the ground
|
||||
|
||||
`engine.ground.createSet({ id, kind, order, pickable, drape })` makes a set
|
||||
of flat fills that drape over the terrain and the 3D tiles. Nothing is
|
||||
extruded.
|
||||
|
||||
Work with a set like this:
|
||||
|
||||
- `replace(items)` checks and stores all its items at once;
|
||||
- `commit()` draws them in place of the last ones;
|
||||
- `show(visible)` shows or hides the whole set;
|
||||
- `list()` names the sets.
|
||||
|
||||
Positions are `[longitude, latitude]` in degrees, as GeoJSON stores them.
|
||||
Edges follow great circles.
|
||||
|
||||
There are four kinds:
|
||||
|
||||
| kind | item | for |
|
||||
| ---------- | ------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `polygon` | `rings: [outer, ...holes]`, `fill`, `outline` | perimeters, airspace, footprints, cones, KML polygons |
|
||||
| `disc` | `center`, `radiusM` (metres along the ground) | earthquakes, air-quality stations, a pad zone |
|
||||
| `cell` | `west`, `south`, `east`, `north` (meridians, parallels) | GNSS and FIRMS cells, privacy cells |
|
||||
| `corridor` | `positions`, `color`, `widthPx` or `widthM` | routes, cables, roads, coverage lines, outlines in metres |
|
||||
|
||||
Item options:
|
||||
|
||||
- Every item has its own colours, rgba in 0..1; the alpha is the opacity.
|
||||
- `fill` and `outline` (`{ color, widthPx }`) are optional; an outline runs
|
||||
along every ring.
|
||||
- Within a set, a later item draws on top. Between sets, a higher `order`
|
||||
draws on top. All ground fills draw under movers, lines and models.
|
||||
- `pickable: true` makes picks return an item's `pickId`, with `owner` (the
|
||||
set) and `instance` (the item's index).
|
||||
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Both engines
|
||||
honour it: Navara tells 3D tiles from terrain by its globe depth, and its
|
||||
CPU picks skip a set draped only on the surface not shown (terrain while
|
||||
photoreal tiles show, tiles otherwise).
|
||||
|
||||
Discs with a fill may use `radial: { fillAlpha, rimAlpha, rimStart, rimEnd,
|
||||
emission }`. Alpha multipliers are in 0..1; the radius fractions satisfy
|
||||
`0 <= rimStart < rimEnd < 1`; emission is nonnegative. The rim grows with
|
||||
smoothstep between the two radii, then fades smoothly to zero at the outer
|
||||
edge. Space Missions uses a 500 m disc with alpha 0.105 inside and 0.72 at
|
||||
the rim, transitions at 0.952 and 0.985, and emission 0.35. Cesium retains
|
||||
its textured ground material and polygon depth bias. Navara composes the
|
||||
same radial envelope in its ground pass after tone mapping.
|
||||
|
||||
An unpickable pixel corridor set with `reveal: true` accepts
|
||||
`setReveal(fraction)` in 0..1. Each path is clipped at that fraction of its
|
||||
length. Geometry stays intact: Cesium updates material uniforms and Navara
|
||||
updates the corridor item texture. Hiding, showing and committing a new
|
||||
path retain the fraction. A corridor may have `backing: { widthPx, color }`:
|
||||
a wider edge with its own colour, with the core blended only once.
|
||||
|
||||
Transit drapes its history and active head through ground corridors. Its
|
||||
completed runs retain their age bands, 5 px dark backing and 3 px colour,
|
||||
with band updates once per displayed second. The active head is one backed
|
||||
corridor revealed along the whole current segment; only a segment crossing
|
||||
rebuilds its geometry. Transit uses dynamic sets so new history and head
|
||||
corridors build synchronously once Cesium's terrain bounds are available.
|
||||
The ground pass handles occlusion on both engines.
|
||||
|
||||
Cesium also builds reveal sets synchronously after loading those bounds.
|
||||
For nonempty sets, `ready()` includes the visible globe's tile readiness:
|
||||
classification needs the current surface depth as well as built geometry.
|
||||
|
||||
A `widthPx` corridor can also take the looks Cesium's ground polyline
|
||||
materials gave it. The GPU works them out every frame from the time, so a
|
||||
commit never rebuilds them:
|
||||
|
||||
| look | fields | Cesium's |
|
||||
| ------- | ------------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `glow` | `power`, `taper` (1: none) | `PolylineGlow`'s `glowPower` and `taperPower` |
|
||||
| `pulse` | `periodMs`, `min`, `max` | the alpha times a factor from `min` to `max` and back, a sine |
|
||||
| `dash` | `cells`, `duty`, `speed`, `gapAlpha`, `emission` | the annotation route's `GevRouteFlow`: dashes flowing to the end |
|
||||
|
||||
- `glow` brightens the line toward white along its centre and fades it to
|
||||
nothing at its edges.
|
||||
- `pulse` follows the page's monotonic clock (`performance.now()`), as the
|
||||
old materials did, not the app clock.
|
||||
- `dash` lays `cells` dashes along the whole line. Each is lit for its first
|
||||
`duty`, with soft edges of 0.08 of a cell, and brightened by `emission`
|
||||
times its colour. Between them the alpha is times `gapAlpha`. The dashes
|
||||
move toward the line's end at `speed` cells a second.
|
||||
- A shown set with a `pulse` or a `dash` makes the engine draw a frame after
|
||||
every frame, as Cesium's continuous render did. `list()` reports it as
|
||||
`animated`. A `glow` alone does not animate.
|
||||
|
||||
Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75,
|
||||
1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.
|
||||
|
||||
A pixel corridor may instead use `dashPattern: { lengthPx, pattern: 255 }`,
|
||||
a static 16-bit screen-space dash mask with transparent gaps, as Cesium's
|
||||
PolylineDash material. It cannot combine with the other corridor looks.
|
||||
Bhote Koshi uses an 18 px dash behind its 5 px glowing flood core.
|
||||
|
||||
Use `dynamic: true` on a set whose path changes each frame. Cesium waits
|
||||
for its shared terrain-height bounds, then builds these small reveals
|
||||
synchronously so successive frames do not discard unfinished worker jobs.
|
||||
Other sets retain asynchronous building.
|
||||
|
||||
### Radial pulse fields
|
||||
|
||||
`engine.ground.createPulseField({ id, center, radiusM, heightM, color,
|
||||
lineColor, haloColor, depth, lineWidth, haloWidth })` draws up to four
|
||||
concentric pulse fronts. The centre is Earth-fixed metres; the radius is
|
||||
metres along the surface. The disc lies on the ellipsoid raised by
|
||||
`heightM` (default 0), with normal scene depth testing. Colours are rgba in
|
||||
0..1, widths are CSS pixels, and `depth` is the inward band depth as a
|
||||
fraction of the radius. A band's opacity falls quadratically to zero.
|
||||
|
||||
Call `draw({ radii, alphas, level })` with up to four radius fractions and
|
||||
opacities in 0..1. Missing fronts are zero. `level` controls visibility;
|
||||
the caller includes it in the alphas. The field starts hidden. `move(center)`
|
||||
follows a new centre; `destroy()` releases it and is safe to repeat.
|
||||
`ground.list()` includes it as `kind: 'pulse-field'`. The caller owns its
|
||||
clock and render requests; the field starts no animation loop.
|
||||
|
||||
Global Context keeps its 2,500 m pulse height, fixed-width front and halo,
|
||||
wake and rest windows, reduced-motion behaviour and 80 ms frame cadence.
|
||||
Cesium retains one material disc, updating its uniforms and rotating for
|
||||
small moves. Navara evaluates the same bands after tone mapping against a
|
||||
raised ellipsoid and scene depth; its shader lives in
|
||||
`src/globe/navara/ground/pulse.js`. The fake engine records the same state.
|
||||
|
||||
Bhote Koshi's event and locator share `src/layers/bhoteKoshi/render/story.js`:
|
||||
the authored reveal properties feed ground corridors and polygons, and
|
||||
point movers retain their independent fill and outline opacity. No story
|
||||
backend is selected by engine. The authored model still uses its existing
|
||||
property records; removing that model dependency is separate work.
|
||||
|
||||
### Ground drawing guarantees
|
||||
|
||||
Both engines preserve item order, fill and outline colours, pixel and metre
|
||||
widths, visibility, and pick ownership. Non-pickable sets let picks through.
|
||||
`ready()` reports when committed geometry is ready to draw. Animated corridor
|
||||
looks use the shared ground time and request frames while shown.
|
||||
|
||||
Cesium translates ground items to classified fills and polylines. Navara
|
||||
classifies visible scene depth and blends the ground in display space.
|
||||
Navara picks ground items at the surface under the pointer. Packing, tiling
|
||||
and bin sizes are implementation details in `src/globe/navara/ground/`.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
`engine.imagery.createLayer({ id, source, alpha, show, split, order, raise })`
|
||||
makes one raster layer draped over the terrain and the 3D tiles, under
|
||||
every ground fill, mover, line and model. Its colours are the image's own,
|
||||
blended by the texel's alpha times the layer's `alpha`.
|
||||
|
||||
There are two kinds of source:
|
||||
|
||||
| kind | source | for |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `tiles` | `url` with `{z}`, `{x}`, `{y}` (and `{s}` of `subdomains`), `minLevel`, `maxLevel`, `tileSize` | Recent Imagery's GIBS days, weather tiles, open overlays |
|
||||
| `image` | `image`: a canvas, an ImageBitmap or image element, or `{ width, height, data }` RGBA bytes; or `url` | the FIRMS heat raster, a field image, event imagery |
|
||||
|
||||
Both take:
|
||||
|
||||
- `rectangle` (`{ west, south, east, north }` in degrees; west > east
|
||||
crosses the antimeridian): where the layer draws, the globe when omitted;
|
||||
- `credit`: HTML shown while the layer is.
|
||||
|
||||
Tiles are Web Mercator XYZ tiles, which stop at ±85.05°, or with
|
||||
`scheme: 'geographic'` EPSG:4326 tiles (2×1 at level 0, y down from 90°N,
|
||||
Cesium's `GeographicTilingScheme`). `opaque` tiles have no alpha channel,
|
||||
and `maxRequests` caps the layer's tile requests in flight.
|
||||
`transform(image)` turns each decoded tile (row 0 on the north edge) into
|
||||
the canvas or image that draws: the weather's infrared mask. An image is
|
||||
stretched over its rectangle in longitude and latitude, row 0 on the north
|
||||
edge; one over the whole globe wraps across 180°.
|
||||
|
||||
`raise: heightM` puts an image layer on a shell that high above the
|
||||
ellipsoid instead of draping it: it draws over terrain, 3D tiles and the
|
||||
sky's edge, hidden only by what is nearer. The shell rises with the camera
|
||||
(4 m per kilometre of camera height, at most 60 km, in 500 m steps) so the
|
||||
coarse 3D tiles seen from far never hide it. Raised layers take image
|
||||
sources only.
|
||||
|
||||
Work with a layer like this:
|
||||
|
||||
- `setSource(source)` draws another source in its place, at its place in
|
||||
the order; the same canvas or bytes again draw their new pixels, and a
|
||||
source that differs only in its `credit` keeps its pixels;
|
||||
- `setSource(source, { staged: true, signal })` keeps the drawn source
|
||||
until the new one is complete (the image loaded; the tiles in view in),
|
||||
then swaps them. It resolves true then, and false when the new source
|
||||
failed, `signal` aborted it, or another `setSource` replaced it first. A
|
||||
hidden layer takes it at once. `staging()` says one is on its way;
|
||||
- `setDetail({ image, rectangle } | null)` (raised layers): inside the
|
||||
rectangle the layer draws the detail image in place of its own (a
|
||||
sharper view of the area in view). The last detail draws until the new
|
||||
one is uploaded; it resolves true once it draws. null clears it;
|
||||
- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its
|
||||
surface, so it comes back without a gap (the FIRMS hand-off);
|
||||
- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles);
|
||||
- `setSplit('left' | 'right' | 'none')` puts it on one side of the split;
|
||||
- `ready()` says the source is built (an image URL loaded); tiles arrive
|
||||
after it;
|
||||
- `destroy()`.
|
||||
|
||||
On the engine:
|
||||
|
||||
- `list()` names the layers in draw order, bottom first: a higher `order`
|
||||
draws on top, then the later layer;
|
||||
- `setSplitPosition(fraction)` sets where the split runs, a fraction of the
|
||||
canvas width from the left (one per engine, Cesium's
|
||||
`scene.splitPosition`); `splitPosition()` reads it;
|
||||
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
|
||||
or `'none'`.
|
||||
|
||||
### Imagery drawing guarantees
|
||||
|
||||
Both engines draw imagery below ground shapes, movers, lines and models,
|
||||
in layer order. Each texel blends by its alpha times the layer alpha.
|
||||
Visibility, split position, rectangle bounds and raised-shell height follow
|
||||
the imagery contract. Replacing or destroying a source releases its owned
|
||||
resources; stale loads cannot replace the current source.
|
||||
|
||||
Cesium translates sources to imagery providers and raised shells. Navara
|
||||
samples imagery in its surface pass, including raised ellipsoid intersections.
|
||||
The adapters own texture storage, tile selection and depth reconstruction.
|
||||
|
||||
Cesium moves draped layers between the visible globe and tileset surfaces;
|
||||
without either surface, layers wait. Its split applies to globe imagery,
|
||||
not raised shells. Navara draws the top eight visible imagery slots, with
|
||||
detail images consuming a slot. Staged sources keep the current image visible
|
||||
until their replacement is ready; a failed source leaves it intact.
|
||||
|
||||
Open map overlays use `engine.imagery` when `maps.addOverlay` is absent.
|
||||
Navara's optional native raster path retains terrain detail, but does not
|
||||
provide rectangle bounds, split, alpha, order, or drawing on 3D tiles.
|
||||
|
||||
### Who draws through imagery
|
||||
|
||||
- **FIRMS heat** (`src/layers/firms/render/display.js`): one image.
|
||||
- **Recent Imagery** (`src/layers/recentImagery/render/display.js`): two
|
||||
Web Mercator tile layers under a split.
|
||||
- **Weather radar, satellite clouds and lightning**
|
||||
(`src/layers/weather/render/display.js`): on the globe, the product's
|
||||
geographic tiles (the regional infrared through its mask, a tile
|
||||
transform), or the global infrared mosaic as one image; each new frame a
|
||||
staged source; clouds, radar and lightning at orders 21, 22, 23. On 3D
|
||||
tiles, a raised layer at the product's shell height
|
||||
(`shellRendering.js` `WEATHER_SHELL_HEIGHTS`) with the detail window
|
||||
around the view as its detail.
|
||||
- **The wind field** (`src/layers/wind/render/display.js`): one image over
|
||||
the globe at order 20, faded with the camera's height while streamlines
|
||||
draw, raised at 5 km on 3D tiles. The streamlines stay each engine's,
|
||||
behind the display's `flow` backend, loaded by the manifest:
|
||||
`src/globe/cesium/windFlow.js` supplies Cesium's streamlines
|
||||
(`src/layers/wind/rendering.js`), and `src/globe/navara/windFlow.js`
|
||||
supplies Navara's flow mesh.
|
||||
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/render/display.js`):
|
||||
two `url` images over the event's rectangle at alpha 0.96, split left
|
||||
and right at the engine's split position, order 15. The event's and the
|
||||
locator's story graphics use the shared ground and mover display described
|
||||
above.
|
||||
|
||||
- **Imported KML/KMZ ground images** (`src/data/geoImportDisplay.js`): one
|
||||
image layer per rectangle at order 30, with the authored alpha capped at
|
||||
0.99 as on Cesium; a tinted or rotated image is drawn on a canvas first,
|
||||
and an absolute one is raised to its altitude.
|
||||
- **Sea Marks and Ski Pistes** (`src/data/osmOverlays.js`): tile layers at
|
||||
order 10 on engines without `maps.addOverlay`.
|
||||
|
||||
## Imported files
|
||||
|
||||
`geoFileImport.js` reads GeoJSON, GPX, KML and KMZ into the plain records in
|
||||
`src/data/geoImportRecords.js`. `geoImportKml.js` resolves KML styles with
|
||||
Cesium's precedence; its tests compare the records with `KmlDataSource`.
|
||||
No import loads the Cesium SDK. One `geoImportDisplay.js` draws the records
|
||||
on every engine: points on movers, ground paths and polygon fills on ground,
|
||||
raised paths and outlines on lines, raised polygon fills on meshes, and packed
|
||||
ground images on imagery.
|
||||
Every owned id starts with `geo-import-`; the file controller owns visibility,
|
||||
framing, refresh and destruction across engine switches.
|
||||
|
||||
Pins use composed square sprites to retain Cesium's pin tip, hot spot,
|
||||
subregion, colour, scale and rotation; a KML heading turns with the camera's
|
||||
heading, so it stays measured from north. Icon textures share a per-file
|
||||
budget of 16 million pixels: many distinct styles get lower-resolution
|
||||
textures at the same size. Ground heights come from the loaded surface,
|
||||
refreshed after the camera or map settles, in batches of 256; on 3D tiles
|
||||
only pins in the window are clamped to the tiles drawn. Ground pins ignore
|
||||
scene depth and are culled beyond the horizon; absolute pins retain their
|
||||
height.
|
||||
|
||||
Raised polygons (KML absolute or relative) draw their fills as meshes and
|
||||
their outlines as lines at the vertices' heights. A ground overlay's colour
|
||||
tints its image and its alpha fades it; a rotated overlay's image is turned
|
||||
on a canvas over the box around it, as Cesium turns its rectangle in degrees,
|
||||
and an absolute overlay is raised to its altitude (Cesium raises only
|
||||
absolute ones). The shared display has known limits against Cesium: wall
|
||||
fills and extruded prisms are omitted (their top geometry remains), and
|
||||
`gx:LatLonQuad`, a rotated overlay whose box passes a pole and a raised
|
||||
plain-colour overlay are omitted. These are counted as undrawn. Ground edges
|
||||
use great circles rather than rhumb lines.
|
||||
|
||||
## Input ownership
|
||||
|
||||
`engine.input.on(type, listener, { pick, drill, pickWindow })` accepts
|
||||
`pickWindow: { width, height }` with positive integer dimensions passed to
|
||||
the picker. Omitted dimensions keep the engine default. Listeners share a
|
||||
pick only when their windows match, and each receives its requested drill
|
||||
limit. Pointer-down picks are synchronous. Moves, including drills, coalesce
|
||||
to the latest position per frame. Navara clicks still use one GPU answer
|
||||
from the gesture; a missing hit falls back to the requested CPU window.
|
||||
Street Level requests 9 × 9 px; CCTV's gizmo requests its own window and
|
||||
limit for presses and hover. Pointer ownership and removal still suppress
|
||||
delivery while a pick is pending.
|
||||
|
||||
`engine.projector.horizonVisible(position, { ellipsoid })` accepts a plain
|
||||
ellipsoid with positive finite `radii: { x, y, z }` in metres. Without the
|
||||
option it uses WGS84. Street Level supplies WGS84 radii reduced by 1,000 m
|
||||
so nearby cones remain visible below sea level. The shared math is tested
|
||||
against the native ellipsoidal occluder, including points near the limb.
|
||||
|
||||
`engine.input.suspendCameraInputs()` disables camera gestures and returns an
|
||||
idempotent resume function. Suspensions are counted: only the last resume
|
||||
restores the state captured before the first suspension. This preserves an
|
||||
already-disabled camera. `cameraInputsEnabled()` reads the current state.
|
||||
Cockpit, imagery-box drawing, CCTV calibration and infrastructure flights use
|
||||
these methods on every engine. Pointer claims remain separate from camera
|
||||
input suspension.
|
||||
|
||||
Pick ownership belongs to the scene attachment, not to an engine implementation.
|
||||
`scene.services.picking` owns its predicates and `scene.services.selection` owns
|
||||
its shared click dispatcher. Layer entry points resolve these services using
|
||||
their attached engine (`sceneServicesFor(engine)`); the pick-registry helper
|
||||
functions take that engine as their final argument. Standalone entries acquire
|
||||
the same services on first use. The application destroys them on detach and
|
||||
creates fresh services when reattaching a parked engine. Identical layer IDs in
|
||||
simultaneous scenes cannot replace or unregister one another's picks.
|
||||
|
||||
## Render demand
|
||||
|
||||
`scene.services.render` owns continuous-render holds and the last 16 idle
|
||||
request reasons for one attachment. `holdContinuousRender(id)` and
|
||||
`releaseContinuousRender(id)` are idempotent per owner; the last release
|
||||
requests one settling frame. `governorRequestRender(reason)` forwards a
|
||||
one-shot request even while another owner holds continuous rendering.
|
||||
The tools call `install()` after scene consumers have registered their holds.
|
||||
|
||||
The governor drives `engine.render` on every engine. Cesium implements
|
||||
`setContinuous` with its scene's `requestRenderMode` and disables automatic
|
||||
simulation-time renders with `maximumRenderTimeChange = Infinity`. Engine
|
||||
internals request their own scene directly and never import the governor.
|
||||
Layer service helpers take their attached engine as the final argument;
|
||||
attachment-bound callbacks can capture `renderGovernorFor(engine)` directly.
|
||||
Destroying the scene clears its holds and makes the captured service inert.
|
||||
A parked engine gets a fresh service when reattached.
|
||||
|
||||
## World overlays
|
||||
|
||||
`scene.services.overlay` owns one attachment's world labels and cards. It
|
||||
keeps its sources, collision domains, paint lanes, canvases, frame listeners,
|
||||
observers, timers and accessible action subscribers. `init()` installs its
|
||||
DOM inside `engine.host.container`; the detection blend surface stays beside
|
||||
the card root so it can blend against the scene.
|
||||
|
||||
Publish with `setEntries(sourceId, entries, options)`, `setVisible(sourceId,
|
||||
visible)` and `clearSource(sourceId)`. Read painted rectangles with
|
||||
`getPaintRect(sourceId, entryId)` and register custom painters with
|
||||
`registerPaintLane(laneId, painter, options)`. `subscribeActions(listener)`
|
||||
provides the accessible mirror; `activateAction(owner, key)` rejects an old
|
||||
host's actions. The shell reconnects these operations on every attachment.
|
||||
|
||||
`worldOverlayFor(engine)` resolves the same service for standalone consumers.
|
||||
The module helper functions take the engine first. Retained layer models use
|
||||
`bindOverlayHost` with their attached-engine getter; injected hosts without a
|
||||
`forEngine` resolver keep their own operations. Publications can buffer before
|
||||
`init()`. Destruction is terminal, including before initialization; a parked
|
||||
engine needs fresh scene services. An old paint-lane handle cannot mutate its
|
||||
replacement. Font measurements are shared until the last live host releases
|
||||
them.
|
||||
|
||||
`scene.services.trackedReadout` bridges the follow handle's label and selected
|
||||
ALPR or Local ADS-B card into this overlay. `init()` subscribes to that engine's
|
||||
follow changes and that attachment's context events; `refresh(entity)` republishes
|
||||
changed label text, and `activeId()` resolves the card for annotation placement.
|
||||
Positions stay live cached visual anchors. `trackedReadoutFor(engine)` resolves
|
||||
the service; destruction removes its listeners and clears only its own source.
|
||||
|
||||
`scene.services.context` owns selection records and notifications. Layer publishers
|
||||
use `bindOverlayHost` with their current engine, or a context service captured by
|
||||
their display. It mirrors records to the page context slot and preserves the
|
||||
`gev:entity-selected`, `gev:entity-selection-cleared` and aircraft awareness event
|
||||
payloads for page consumers. A clear reaches the page only when that attachment
|
||||
owns the page's selected record. Readouts listen only to their scene's event target.
|
||||
Identical IDs in different scenes cannot share a card or clear one another's
|
||||
selection. Retired context services cannot publish into a replacement attachment.
|
||||
An engine handoff carries the page-selected record through the layer session.
|
||||
The incoming context adopts it only while the page still selects that record;
|
||||
a clear between attachments wins. Rebuilt markers replace the retained carrier
|
||||
in the incoming readout without announcing another page selection.
|
||||
Readout initialization reads the retained record without testing the outgoing
|
||||
carrier's visibility; layers rebuild their carriers after camera restoration.
|
||||
The page chrome and detection controller still have page lifetimes.
|
||||
|
||||
## Render effects
|
||||
|
||||
The optional `visualStyles`, `bloom` and `contactSonar` capabilities are
|
||||
available on Cesium and Navara through `engine.render`. Cesium implements
|
||||
styles, sharpening, borrowed bloom and the Anime Milky Way fade in
|
||||
`src/globe/cesium/postStages.js`; contact sonar is in `sonar.js` and
|
||||
`sonarCommands.js`. Cesium does not advertise `cloudCover`.
|
||||
|
||||
`VisualEffects` drives transitions through the render contract. UI teardown
|
||||
stops animation and disables effects; engine destruction removes its stages
|
||||
and restores the borrowed bloom state. Sharpen intensity zero disables
|
||||
sharpening on both engines, following Cesium.
|
||||
|
||||
## Wildlife privacy cells
|
||||
|
||||
Wildlife privacy cells use one `cell` ground set for distinct bounds and taxon
|
||||
accents. The fill is 25% opaque and the 2 px outline is 80% opaque, matching
|
||||
Cesium's original ground geometry. Both drape on terrain and tiles and pass
|
||||
picks through to the badges. Wide views and detach release the set.
|
||||
|
||||
## Batched line segments
|
||||
|
||||
`engine.lines.createSegments({ id, widthPx, color, order })` groups independent
|
||||
straight segments in one batch. `replace(items)` copies pairs of Earth-fixed
|
||||
endpoints and optional per-item `widthPx`, `color` and `pickId`; `commit()`
|
||||
rebuilds the drawing once. Segments never connect to one another. `show()`
|
||||
and `destroy()` affect the whole batch; an empty replacement clears it.
|
||||
The batch shares `createLine`'s id namespace and `list()`, which includes its
|
||||
endpoint count and committed `pickIds` in item order.
|
||||
|
||||
Cesium draws one synchronous primitive with per-instance colours and widths.
|
||||
Navara draws one ribbon mesh in the overlay after tone mapping, with scene
|
||||
depth and independent segment picks. Batches are solid and depth-tested;
|
||||
curved, dashed, transformed and depth-fail lines still use `createLine`.
|
||||
Infrastructure and cable references each use one batch for their visible
|
||||
stems, preserving Cesium's colours, widths, label winners and update cadence.
|
||||
Infrastructure excludes the whole stem batch while sampling its bases.
|
||||
|
||||
### Choosing a primitive
|
||||
|
||||
- **A position and an on-screen size:** a mover.
|
||||
- **A line at given heights, or one that moves or is restyled every frame**
|
||||
(trails, orbits, a ring at the terrain height): a line.
|
||||
- **Anything that lies on the ground** and follows its relief:
|
||||
- an area: `polygon`, or `disc` or `cell` when that is its shape (they
|
||||
are exact and cheaper);
|
||||
- a line on the ground: `corridor`, with `widthM` when its width is a
|
||||
real width (an outline in metres) and `widthPx` otherwise.
|
||||
- Group items that change together into one set, and replace them in bulk.
|
||||
A ground set rebuilds on commit. Small paths revealed each frame use
|
||||
`dynamic: true`; corridor looks animate without a rebuild, and radial
|
||||
fronts use `ground.createPulseField`.
|
||||
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
|
||||
Fade it with `setAlpha`, and give a new raster to `setSource`.
|
||||
|
||||
## Meshes: world triangles
|
||||
|
||||
`engine.meshes.createMesh({ id, positions, indices, uv, image, color, show,
|
||||
depthTest, pickId })` draws indexed triangles on both sides. Positions are
|
||||
flat Earth-fixed xyz metres and indices name their vertices. Optional UVs run
|
||||
from (0, 0) at the image's bottom left to (1, 1) at its top right. The engine
|
||||
copies geometry arrays; the caller owns the canvas, image or video.
|
||||
|
||||
`update(patch)` replaces only supplied fields. Repeating `image` uploads new
|
||||
canvas pixels; videos update on rendered frames. RGBA `color` multiplies the
|
||||
image, white by default. `show()` reads visibility and `show(boolean)` changes
|
||||
it. `depthTest` is `normal` or `always`; null `pickId` disables picking. Picks
|
||||
return the authored id and the mesh id as owner. `destroy()` is idempotent;
|
||||
`engine.meshes.list()` returns live ids in creation order.
|
||||
|
||||
Cesium draws a synchronous Primitive with a flat material, no back-face
|
||||
culling and no depth writes. Colour, image and visibility changes retain the
|
||||
primitive; geometry, pick-id and depth-test changes replace it. The engine
|
||||
requests frames while the primitive or its image is preparing. Navara draws
|
||||
local coordinates relative to a double-precision world origin in its
|
||||
transparent scene pass after tone mapping, with scene depth and triangle
|
||||
picking. Their authored colours are blended in display space.
|
||||
Both engines own geometry, material and texture disposal. The fake engine
|
||||
keeps the same state and tests triangle intersections for picks.
|
||||
|
||||
CCTV's `render/meshes.js` describes its live monitor quad and viewshed pyramid
|
||||
through this primitive. Its markers and outlines remain movers and lines.
|
||||
Obstruction probes use `engine.surface.raycast`; exclusions accept owned mesh,
|
||||
line and mover handles on Cesium and restore their visibility after the query.
|
||||
|
||||
Data packs use one `src/scenes/dataPacks/display.js`: points on movers,
|
||||
straight raised paths on lines, raised polygons and image rectangles on
|
||||
meshes, and media markers with captions in a composed sprite. Polygon holes
|
||||
and authored vertex heights are retained. Shared triangulation uses `earcut`,
|
||||
with tests comparing its area to Cesium's polygon geometry. Images retain
|
||||
Cesium's one-degree rectangle grid. Scene detach releases drawn resources and
|
||||
pick targets; reattach redraws the retained decoded assets.
|
||||
|
||||
Annotation drapes still use their existing renderers. Their bounded building
|
||||
classification volumes, animated fills and wire cages are not ordinary world
|
||||
triangles; they need a classification contract before that migration can
|
||||
preserve Cesium's behaviour.
|
||||
|
||||
## Shared follow offsets and manual drawing
|
||||
|
||||
`engine.camera.follow({ position, viewFrom, swingTo })` can swing from the
|
||||
initial follow frame to `swingTo`, an east/north/up offset in metres. Each
|
||||
frame turns at most 60 degrees and interpolates range, preserving Cesium's
|
||||
tracked-camera crossing guard. `handle.retarget({ swingTo })` changes only
|
||||
the pending swing; `null` cancels it. Stopping or replacing the follow, a
|
||||
camera command or engine destruction ends it. Share links read the current
|
||||
handle's target and label identity and the camera pose through the contract.
|
||||
|
||||
`engine.input.acquirePointer()` returns an exclusive claim, or `null` if
|
||||
one is held. Pass it to `input.on(type, listener, { pointer: claim })` and
|
||||
call `claim.release()` when done. Other screen subscriptions yield, including
|
||||
picks that settle after ownership changes. Camera gestures stay enabled.
|
||||
Cesium suspends its stock select and track clicks and restores the original
|
||||
functions when the claim is released or the engine is destroyed.
|
||||
|
||||
The Draw tool uses one `drawSurface.js` on every engine: a dashed geodesic
|
||||
line and a point mover set at the clicked heights. The dim, occluded band
|
||||
uses the selected colour as well as the visible band. Finished geometry still
|
||||
goes through the annotation board. Detaching the scene releases the claim,
|
||||
listeners and preview while retaining the chosen shape and colour. The
|
||||
page's pointer lease still arbitrates Draw against other tools.
|
||||
|
||||
## How to add an engine
|
||||
|
||||
Put the implementation in a new `src/globe/<name>/` folder. Keep SDK classes,
|
||||
shaders, resource disposal and SDK workarounds there. Use
|
||||
`src/globe/testing/fakeEngine.js` as the contract model, and
|
||||
`src/globe/contract.js` as the authoritative member and data-shape reference.
|
||||
Coordinates, colours, picks and display options crossing the boundary are plain
|
||||
data; displays do not receive native viewers or SDK types.
|
||||
|
||||
Implement the required host, camera, picker, projector, frame, render, surface,
|
||||
input, credit, clock and map interfaces, plus all five drawing primitives:
|
||||
`movers`, `lines`, `ground`, `imagery` and `meshes`. `assertGlobeEngine(engine)`
|
||||
checks these required interfaces. `engineCapabilities(engine)` reports only the
|
||||
optional visualStyles, bloom, cloudCover and contactSonar groups. An optional
|
||||
group must be complete or absent. Do not advertise a capability with a no-op implementation. Match Cesium's behavior where the
|
||||
contract permits both engines to implement it.
|
||||
|
||||
Run `createContractSuite(engine, { waitFrames, ... })` from
|
||||
`src/globe/testing/contractSuite.mjs`. Supply real pointer delivery, canvases and
|
||||
pixel reads for browser checks; keep the fake-engine tests as fast contract
|
||||
checks. Test ownership and repeated destruction as well as visible output.
|
||||
The browser harness is `scripts/qa-contract-suite.mjs`; use
|
||||
`--only=ground:,imagery:` for ground and imagery pixel checks.
|
||||
|
||||
The application currently registers two kinds: adding another still requires
|
||||
registration in `ENGINE_KINDS`, scene construction/choice and the corresponding
|
||||
QA harnesses. `assertGlobeEngine` currently validates that registration. The
|
||||
folder and passing contract suite are the implementation boundary, not a claim
|
||||
that arbitrary engine folders are auto-discovered today. Layers and their
|
||||
fixtures should need no new drawing implementation.
|
||||
|
||||
The layer manifest (`src/app/layers/renderers.js`) loads shared factories through
|
||||
`display`. Every valid engine implements all five primitives; the manifest has
|
||||
no per-kind primitive lists or `needs` declarations. `backends` supplies optional
|
||||
engine-specific display options, currently only wind's `flow`, loaded dynamically
|
||||
from its engine folder. A missing wind backend leaves the shared raster field
|
||||
available. Self-drawing layers and map overlays use the same contract and do not
|
||||
register supported engine kinds in the manifest.
|
||||
|
||||
Run `src/app/layers/displayRehearsal.test.mjs`: it loads every manifest display
|
||||
on the fake engine with an unknown kind and empty extensions. Its small fixtures
|
||||
exercise attachment, drawing, hiding or release, and destruction; primitive
|
||||
registries, frame/camera/input subscriptions, follow state and service records
|
||||
must be clean before the engine itself is destroyed. New display entries need a
|
||||
fixture. Wildlife Sightings and the two map overlays are explicit skips because
|
||||
they have no display factory; their layer tests remain the coverage for those
|
||||
controllers. Native wind streamlines, DOM rasterization and GPU pixels are not
|
||||
covered by this rehearsal.
|
||||
|
||||
`node scripts/check-import-directions.mjs` enforces SDK ownership for static
|
||||
imports, re-exports and literal dynamic imports, including package subpaths.
|
||||
Cesium packages belong in `src/globe/cesium/`; three, postprocessing and Navara
|
||||
packages belong in `src/globe/navara/`. Add the new SDK's ownership rule when
|
||||
registering it. Contract graphs cannot reach any engine, and the existing
|
||||
engines cannot reach one another. The temporary exact file/package exceptions
|
||||
in `scripts/package-boundaries.json` must have reasons and must still be used;
|
||||
the checker prints them. They are migration debt, not a pattern for new layers.
|
||||
Run package boundaries, formatting, the full unit suite and the coupling report
|
||||
as well; engine coupling counts may not grow.
|
||||
@@ -3,7 +3,7 @@
|
||||
`gods-eye-view/infrastructure` exports `createInfrastructureLayers(services)`.
|
||||
It returns fresh datacenter and dam layers in that order, with the existing IDs,
|
||||
appearance, label budgets and bundled public datasets. Importing the module or
|
||||
calling the factory does not load data, create a viewer, or start the application.
|
||||
calling the factory does not load data, create an engine, or start the application.
|
||||
The asset URLs resolve relative to the module; Vite rewrites them for production
|
||||
base paths. This browser source export expects a Vite-compatible asset build.
|
||||
|
||||
@@ -21,7 +21,13 @@ const layers = createInfrastructureLayers({
|
||||
governorRequestRender,
|
||||
});
|
||||
|
||||
// Register these layers with the existing data-layer manager and viewer.
|
||||
// Attach each layer to the current engine, then enable it when requested.
|
||||
for (const layer of layers) layer.init(null, { engine });
|
||||
await layers[0].enable();
|
||||
|
||||
// On a scene switch, keep the parsed data and replace only its display.
|
||||
for (const layer of layers) layer.detachRenderer();
|
||||
for (const layer of layers) await layer.attachRenderer(null, { engine: nextEngine });
|
||||
```
|
||||
|
||||
| Function | Arguments / responsibility |
|
||||
@@ -35,28 +41,39 @@ const layers = createInfrastructureLayers({
|
||||
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
|
||||
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
|
||||
|
||||
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
|
||||
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
|
||||
`getAnalystRecords()`.
|
||||
Use the same viewer for the instance's entire lifetime. Each viewer/context/overlay
|
||||
host permits one live instance per layer ID; destroy an old instance before
|
||||
replacing it. Separate applications may use identical IDs with separate hosts.
|
||||
Callbacks must be ready before enable and remain usable through destruction.
|
||||
Each returned session layer supports `init(null, { engine })`,
|
||||
`attachRenderer(null, { engine, signal })`, `detachRenderer()`, `enable()`,
|
||||
`disable()`, `update()`, `destroy()`, `getStats()`, `getLodDiagnostics()` and
|
||||
`getAnalystRecords(limit)`. The first argument is retained for lifecycle
|
||||
compatibility; drawing uses the engine passed in the options. Attach once per
|
||||
scene, detach before attaching another engine, and destroy when the session ends.
|
||||
Separate applications may use identical IDs with separate context/overlay hosts.
|
||||
Callbacks must remain usable through destruction.
|
||||
|
||||
Disable retains the loaded dataset for reuse and clears selection and listeners.
|
||||
Concurrent enables share one load. Destroy is permanent, aborts pending fetches,
|
||||
discards late results, and clears the owned data source, context records, overlays,
|
||||
input handler and listeners. The layer never creates an application context store,
|
||||
overlay host, viewer, or render scheduler. The consumer must resolve one compatible
|
||||
Cesium installation shared with its viewer; it must not bundle separate copies.
|
||||
`src/layers/infrastructure/index.js` owns the parsed dataset, shared in-flight
|
||||
load, enabled intention and selection across scene switches. Enable loads on
|
||||
first use. Disable hides the drawing and clears selection but retains parsed
|
||||
data. Detach destroys scene resources and retains the dataset and selected ID;
|
||||
reattach redraws enabled data and restores selection when no other selection
|
||||
owns the context. The attachment signal cancels that scene's wait, not the
|
||||
session's fetch. Destroy aborts the fetch, discards late results and releases
|
||||
both data and drawing.
|
||||
|
||||
`gods-eye-view/infrastructure/geojson` exports the lower-level
|
||||
`createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay
|
||||
helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers.
|
||||
Consumers use the package exports, which do not import standalone application
|
||||
globals.
|
||||
`src/layers/infrastructure/render/display.js` draws the same records on every
|
||||
engine: point movers for tips and pins, lines for stems, ground polygons for
|
||||
footprints and ground corridors for paths. It owns the LOD walk, surface-height
|
||||
refresh, picks, context handles and overlay publications. Destruction releases
|
||||
its primitives and frame, camera and input subscriptions. The layer never
|
||||
creates an overlay host, engine or render scheduler and does not import Cesium.
|
||||
|
||||
`gods-eye-view/infrastructure/geojson` exports the overlay publication,
|
||||
selection, label and error helpers in `src/data/localGeojsonCore.js`; it no
|
||||
longer exports `createLocalGeoJsonLayer`. `gods-eye-view/infrastructure/lod`
|
||||
exports the LOD policy helpers. The package exports do not import standalone
|
||||
application globals.
|
||||
|
||||
Dataset files and source/license notices remain under `src/data/local_data/`.
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
|
||||
expand multipart geometries into multiple entities; entity and stem counts are
|
||||
different measurements. This change does not refresh or relicense the datasets.
|
||||
Their input counts are 4,352 datacenter features and 704 dam features. The session
|
||||
splits multipart geometries using Cesium's stable repeated-id suffixes on every
|
||||
engine. Feature, mover and stem counts are different measurements. These files
|
||||
and licenses are independent of the renderer implementation.
|
||||
@@ -106,7 +106,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
||||
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
|
||||
| 13 | thing | LEN | none | sub-message, see thing views |
|
||||
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
|
||||
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm, sentinel2 |
|
||||
| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber |
|
||||
| 17 | `bloomIntensity` | uint | style | |
|
||||
| 18 | `bloomVersion` | uint | 2 | |
|
||||
|
||||
@@ -270,9 +270,9 @@ a feature supplying only its accent.
|
||||
the tracked-entity fade is measured once the frame is in the document;
|
||||
the styles it injected now live in `src/ui/styles/annotations.css`), the
|
||||
visible map target list (the scene chrome renders the buttons and status
|
||||
the world overlay presents through `subscribeWorldOverlayActions()`, which
|
||||
the world overlay presents through `subscribeActions()`, which
|
||||
the application shell publishes to the `gev-world-overlay-actions` store
|
||||
and answers with `activateWorldOverlayAction()`; the module needs no Vue
|
||||
and answers with `activateAction()`; the module needs no Vue
|
||||
for the portable transit entry, and its card and detection canvases stay
|
||||
painting surfaces it draws on, like the Cesium canvas), the Global
|
||||
Context compass and contact bearings (`AwarenessDirection` teleports into
|
||||
|
||||
Generated
+1113
-5
File diff suppressed because it is too large.
Load diff
+11
-4
@@ -54,21 +54,28 @@
|
||||
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
|
||||
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||
"qa:transit": "node scripts/qa-transit.mjs",
|
||||
"mcp": "node server/mcp/stdio.js"
|
||||
"mcp": "node server/mcp/stdio.js",
|
||||
"check:main-chunk": "node scripts/check-main-chunk.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jtarrio/signals": "^0.10.0",
|
||||
"@jtarrio/webrtlsdr": "^3.0.6",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"@navaramap/three": "0.2.1",
|
||||
"@navaramap/three-default-plugin": "0.2.1",
|
||||
"cesium": "^1.124.0",
|
||||
"earcut": "^3.0.2",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"hls.js": "^1.7.3",
|
||||
"kdbush": "4.0.2",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mgrs": "^2.1.0",
|
||||
"pbf": "^5.1.2",
|
||||
"pinia": "^2.3.1",
|
||||
"postprocessing": "6.39.4",
|
||||
"satellite.js": "^6.0.2",
|
||||
"three": "0.185.1",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.15"
|
||||
},
|
||||
@@ -89,7 +96,7 @@
|
||||
"./infrastructure/geojson": "./src/data/localGeojsonCore.js",
|
||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||
"./application": "./src/app/application.js",
|
||||
"./application/viewer": "./src/app/viewer.js",
|
||||
"./application/viewer": "./src/globe/cesium/viewer.js",
|
||||
"./build/vite": {
|
||||
"node": "./build/vite.js"
|
||||
},
|
||||
@@ -198,8 +205,8 @@
|
||||
"./geography/neighborhoods": "./src/data/neighborhoodPolygons.js",
|
||||
"./maps/controller": "./src/maps/controller.js",
|
||||
"./maps/catalog": "./src/maps/catalog.js",
|
||||
"./maps/imagery": "./src/maps/imagery.js",
|
||||
"./maps/terrain": "./src/maps/terrain.js",
|
||||
"./maps/imagery": "./src/globe/cesium/mapImagery.js",
|
||||
"./maps/terrain": "./src/globe/cesium/terrain.js",
|
||||
"./maps/3d": "./src/maps/google3d.js",
|
||||
"./maps/defaults": "./src/maps/defaultSources.js",
|
||||
"./voice/controller": "./src/voice/realtimeController.js",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// The production build replaces every `cesium` import with the global that
|
||||
// /cesium/Cesium.js defines, and that script loads only when a Cesium globe
|
||||
// first starts (src/app/loadCesium.js). A module that reads a Cesium binding
|
||||
// while it loads would therefore throw on a page that starts on Navara. This
|
||||
// check lists such reads: a reference to a binding imported from `cesium`, or
|
||||
// to the `Cesium` global, that runs when its module is evaluated (top level,
|
||||
// class heritage, static members, immediately invoked functions) rather than
|
||||
// inside a function that runs later.
|
||||
//
|
||||
// node scripts/check-cesium-load-time.mjs [--json]
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parse } from '@babel/parser';
|
||||
import { vueScripts } from './vue-scripts.mjs';
|
||||
|
||||
const root = new URL('..', import.meta.url);
|
||||
|
||||
/** Files under src/ that the app ships (tests and test support excluded). */
|
||||
export function sourceFiles() {
|
||||
return execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', 'src'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(
|
||||
(path) =>
|
||||
/\.(?:js|mjs|vue)$/.test(path) &&
|
||||
!/\.test\.m?js$/.test(path) &&
|
||||
!path.startsWith('src/testSupport/') &&
|
||||
!path.includes('/testing/') &&
|
||||
existsSync(new URL(path, root)),
|
||||
);
|
||||
}
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'FunctionDeclaration',
|
||||
'FunctionExpression',
|
||||
'ArrowFunctionExpression',
|
||||
'ObjectMethod',
|
||||
'ClassMethod',
|
||||
'ClassPrivateMethod',
|
||||
]);
|
||||
|
||||
/** Names a module binds to `cesium` imports. */
|
||||
function cesiumBindings(program) {
|
||||
const names = new Set();
|
||||
for (const node of program.body) {
|
||||
if (node.type !== 'ImportDeclaration' || node.source.value !== 'cesium')
|
||||
continue;
|
||||
for (const specifier of node.specifiers) names.add(specifier.local.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function isReference(node, parent, key) {
|
||||
if (node.type !== 'Identifier') return false;
|
||||
if (!parent) return true;
|
||||
if (
|
||||
(parent.type === 'MemberExpression' ||
|
||||
parent.type === 'OptionalMemberExpression') &&
|
||||
key === 'property' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
(parent.type === 'ObjectProperty' ||
|
||||
parent.type === 'ClassProperty' ||
|
||||
parent.type === 'ObjectMethod' ||
|
||||
parent.type === 'ClassMethod') &&
|
||||
key === 'key' &&
|
||||
!parent.computed
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
parent.type === 'ImportSpecifier' ||
|
||||
parent.type === 'ImportNamespaceSpecifier' ||
|
||||
parent.type === 'ImportDefaultSpecifier' ||
|
||||
(parent.type === 'ExportSpecifier' && key !== 'local')
|
||||
)
|
||||
return false;
|
||||
if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement')
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load-time references in one module. `deferred` is true below a function
|
||||
* body that runs later; an immediately invoked function and a class's static
|
||||
* members and heritage run at load and stay not deferred.
|
||||
*/
|
||||
export function loadTimeReferences(code, path = '<module>') {
|
||||
if (path.endsWith('.vue'))
|
||||
return vueScripts(code, path).flatMap((script) =>
|
||||
loadTimeReferences(script).map((reference) => ({ ...reference, path })),
|
||||
);
|
||||
const program = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['classProperties', 'classStaticBlock', 'importAttributes'],
|
||||
}).program;
|
||||
const names = cesiumBindings(program);
|
||||
const found = [];
|
||||
// A re-export from `cesium` becomes a read of the global's property when
|
||||
// the module loads.
|
||||
for (const node of program.body)
|
||||
if (
|
||||
(node.type === 'ExportNamedDeclaration' ||
|
||||
node.type === 'ExportAllDeclaration') &&
|
||||
node.source?.value === 'cesium'
|
||||
)
|
||||
found.push({
|
||||
path,
|
||||
line: node.loc.start.line,
|
||||
name: 'export from cesium',
|
||||
});
|
||||
const hasGlobal = /\bCesium\b/.test(code);
|
||||
if (!names.size && !hasGlobal) return found;
|
||||
// A `Cesium` global reference counts when the module does not bind it.
|
||||
const watched = (name) =>
|
||||
names.has(name) || (name === 'Cesium' && !names.has('Cesium'));
|
||||
const visit = (node, parent, key, deferred) => {
|
||||
if (!node || typeof node.type !== 'string') return;
|
||||
if (node.type === 'ImportDeclaration') return;
|
||||
if (!deferred && isReference(node, parent, key) && watched(node.name))
|
||||
found.push({ path, line: node.loc.start.line, name: node.name });
|
||||
let childDeferred = deferred;
|
||||
if (FUNCTION_TYPES.has(node.type)) {
|
||||
const invoked =
|
||||
parent &&
|
||||
(parent.type === 'CallExpression' || parent.type === 'NewExpression') &&
|
||||
key === 'callee';
|
||||
childDeferred = deferred || !invoked;
|
||||
}
|
||||
if (
|
||||
(node.type === 'ClassProperty' || node.type === 'ClassPrivateProperty') &&
|
||||
!node.static
|
||||
)
|
||||
childDeferred = true;
|
||||
for (const [childKey, value] of Object.entries(node)) {
|
||||
if (
|
||||
childKey === 'loc' ||
|
||||
childKey === 'leadingComments' ||
|
||||
childKey === 'trailingComments' ||
|
||||
childKey === 'innerComments'
|
||||
)
|
||||
continue;
|
||||
// Computed keys and parameter defaults of a deferred function: its key
|
||||
// is evaluated with the enclosing scope, its body later.
|
||||
const keyDeferred =
|
||||
FUNCTION_TYPES.has(node.type) && childKey === 'key'
|
||||
? deferred
|
||||
: childDeferred;
|
||||
if (Array.isArray(value))
|
||||
for (const child of value) visit(child, node, childKey, keyDeferred);
|
||||
else if (value && typeof value === 'object')
|
||||
visit(value, node, childKey, keyDeferred);
|
||||
}
|
||||
};
|
||||
visit(program, null, null, false);
|
||||
return found;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const found = [];
|
||||
for (const path of sourceFiles()) {
|
||||
const code = readFileSync(new URL(path, root), 'utf8');
|
||||
try {
|
||||
found.push(...loadTimeReferences(code, path));
|
||||
} catch (error) {
|
||||
console.error(`${path}: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
if (process.argv.includes('--json'))
|
||||
console.log(JSON.stringify(found, null, 2));
|
||||
else
|
||||
for (const { path, line, name } of found)
|
||||
console.log(`${path}:${line} reads ${name} while the module loads`);
|
||||
if (found.length) {
|
||||
console.error(
|
||||
`${found.length} load-time Cesium read(s); a page that starts on Navara has no Cesium global yet.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else console.log('No module reads Cesium while it loads.');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { loadTimeReferences, sourceFiles } from './check-cesium-load-time.mjs';
|
||||
|
||||
const reads = (code) =>
|
||||
loadTimeReferences(code).map(({ line, name }) => `${line}:${name}`);
|
||||
|
||||
test('reads that run while a module loads are found; reads inside later code are not', () => {
|
||||
assert.deepEqual(
|
||||
reads(`import * as Cesium from 'cesium';
|
||||
const color = Cesium.Color.RED;
|
||||
export function later() { return Cesium.Color.BLUE; }
|
||||
const lazy = () => Cesium.Math.PI;
|
||||
class Shape { static axis = Cesium.Axis.Y; field = Cesium.Axis.Z; draw() { return Cesium; } }
|
||||
(() => Cesium.VERSION)();
|
||||
export const fallback = (sdk = Cesium) => sdk;`),
|
||||
['2:Cesium', '5:Cesium', '6:Cesium'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reads(`import { Cartesian3 as C3, Color } from 'cesium';
|
||||
export const ORIGIN = C3.ZERO;
|
||||
export function white() { return Color.WHITE; }`),
|
||||
['2:C3'],
|
||||
);
|
||||
assert.deepEqual(reads(`export { Axis } from 'cesium';`), [
|
||||
'1:export from cesium',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
reads(`window.__CESIUM__ = Cesium;`),
|
||||
['1:Cesium'],
|
||||
'the global counts where the module does not import it',
|
||||
);
|
||||
assert.deepEqual(reads(`const geo = { Cesium: 1 }; geo.Cesium;`), []);
|
||||
});
|
||||
|
||||
test('no shipped module reads Cesium while it loads', () => {
|
||||
const found = sourceFiles().flatMap((path) =>
|
||||
loadTimeReferences(readFileSync(path, 'utf8'), path),
|
||||
);
|
||||
assert.deepEqual(found, []);
|
||||
});
|
||||
|
||||
test('re-exporting imported Cesium bindings reads them while loading', () => {
|
||||
assert.deepEqual(
|
||||
reads("import { Color } from 'cesium'; export { Color as Tint };"),
|
||||
['1:Color'],
|
||||
);
|
||||
assert.deepEqual(reads("import * as C from 'cesium'; export { C };"), [
|
||||
'1:C',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Vue scripts participate in the load-time check with original line numbers', () => {
|
||||
assert.deepEqual(
|
||||
loadTimeReferences(
|
||||
`<template><span>Cesium</span></template>
|
||||
<script>
|
||||
import { Color } from 'cesium';
|
||||
export const tint = Color.RED;
|
||||
</script>`,
|
||||
'src/View.vue',
|
||||
).map(({ line, name }) => `${line}:${name}`),
|
||||
['4:Color'],
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { analyzeModule } from './module-analysis.mjs';
|
||||
const builtins = new Set(
|
||||
builtinModules.flatMap((name) => [name, `node:${name}`]),
|
||||
);
|
||||
const code = /\.[mc]?js$/;
|
||||
const code = /\.(?:[mc]?js|vue)$/;
|
||||
const entry = (file) =>
|
||||
file === 'src/main.js' || file.startsWith('src/standalone/');
|
||||
const tests = (file) =>
|
||||
@@ -19,6 +19,17 @@ const tests = (file) =>
|
||||
const source = (file) =>
|
||||
file.startsWith('src/sources/') ||
|
||||
/^src\/layers\/[^/]+\/(?:source|bundledSource|flowSource)\.js$/.test(file);
|
||||
// Engine-neutral geometry ported from Cesium; every runtime file is a portable root.
|
||||
const geo = (file) => file.startsWith('src/geo/');
|
||||
// The app clock (src/time): plain numbers, no engine, no DOM.
|
||||
const time = (file) => file.startsWith('src/time/');
|
||||
// The engine-neutral globe contract and its test support; engines live below it.
|
||||
const globeContract = (file) =>
|
||||
/^src\/globe\/[^/]+$/.test(file) || file.startsWith('src/globe/testing/');
|
||||
const globeEngine = (file) => /^src\/globe\/(?:cesium|navara)\//.test(file);
|
||||
const cesiumPackage = /^(?:cesium(?:\/|$)|@cesium\/)/;
|
||||
// Navara renders through three.js and its postprocessing peer.
|
||||
const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
||||
const renderer = (file) =>
|
||||
/^src\/(?:ui|app|standalone|overlays)\//.test(file) ||
|
||||
/^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test(
|
||||
@@ -70,6 +81,25 @@ export function checkImportDirections(root) {
|
||||
(file) =>
|
||||
code.test(file) && !tests(file) && existsSync(path.join(root, file)),
|
||||
);
|
||||
const boundaryFile = path.join(root, 'scripts/package-boundaries.json');
|
||||
const { engineImportExceptions = [] } = existsSync(boundaryFile)
|
||||
? JSON.parse(readFileSync(boundaryFile, 'utf8'))
|
||||
: {};
|
||||
const exceptionKeys = new Set();
|
||||
for (const item of engineImportExceptions) {
|
||||
const key = `${item.file}:${item.package}`;
|
||||
if (
|
||||
typeof item.file !== 'string' ||
|
||||
!files.includes(item.file) ||
|
||||
!(cesiumPackage.test(item.package) || navaraPackage.test(item.package)) ||
|
||||
typeof item.reason !== 'string' ||
|
||||
!item.reason.trim() ||
|
||||
exceptionKeys.has(key)
|
||||
)
|
||||
throw new Error(`Invalid engine import exception: ${key}`);
|
||||
exceptionKeys.add(key);
|
||||
}
|
||||
const usedExceptions = new Set();
|
||||
const errors = new Set();
|
||||
const modules = new Map();
|
||||
const relative = (file) =>
|
||||
@@ -87,7 +117,10 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
let analysis;
|
||||
try {
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'));
|
||||
analysis = analyzeModule(readFileSync(resolved, 'utf8'), {
|
||||
dynamic: true,
|
||||
filename: file,
|
||||
});
|
||||
} catch (error) {
|
||||
report(file, error.message);
|
||||
return null;
|
||||
@@ -109,6 +142,19 @@ export function checkImportDirections(root) {
|
||||
target = path.resolve(root, exported);
|
||||
} else {
|
||||
record.external.push(specifier);
|
||||
const owner = cesiumPackage.test(specifier)
|
||||
? 'cesium'
|
||||
: navaraPackage.test(specifier)
|
||||
? 'navara'
|
||||
: null;
|
||||
if (owner && !file.startsWith(`src/globe/${owner}/`)) {
|
||||
const exception = engineImportExceptions.find(
|
||||
(item) => item.file === file && item.package === specifier,
|
||||
);
|
||||
if (exception) usedExceptions.add(exception);
|
||||
else
|
||||
report(file, `${specifier} imports belong in src/globe/${owner}/`);
|
||||
}
|
||||
if (
|
||||
file.startsWith('src/') &&
|
||||
(builtins.has(specifier) || specifier.startsWith('node:'))
|
||||
@@ -131,6 +177,18 @@ export function checkImportDirections(root) {
|
||||
continue;
|
||||
}
|
||||
record.edges.push(to);
|
||||
// Navara stays out of the Cesium-mode bundle: outside its own directory
|
||||
// it is reached only through import() (src/app/scene.js), which the
|
||||
// bundler splits into a chunk loaded on ?engine=navara.
|
||||
if (
|
||||
to.startsWith('src/globe/navara/') &&
|
||||
!file.startsWith('src/globe/navara/') &&
|
||||
analysis.staticImports.includes(specifier)
|
||||
)
|
||||
report(
|
||||
file,
|
||||
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||
);
|
||||
if (file.startsWith('src/') && to.startsWith('server/'))
|
||||
report(file, `Browser imports server: ${to}`);
|
||||
if (
|
||||
@@ -177,6 +235,8 @@ export function checkImportDirections(root) {
|
||||
for (const file of files.filter((file) => file.startsWith('src/')))
|
||||
browser(file);
|
||||
const roots = new Map(files.filter(source).map((file) => [file, 'source']));
|
||||
for (const file of files.filter(geo)) roots.set(file, 'geo');
|
||||
for (const file of files.filter(time)) roots.set(file, 'time');
|
||||
for (const [key, value] of Object.entries(pkg.exports || {})) {
|
||||
if (portableExport(key) && typeof value === 'string')
|
||||
roots.set(value.replace(/^\.\//, ''), key);
|
||||
@@ -202,7 +262,12 @@ export function checkImportDirections(root) {
|
||||
if (
|
||||
builtins.has(specifier) ||
|
||||
specifier.startsWith('node:') ||
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier)
|
||||
/^(?:cesium|vite)(?:\/|$)/.test(specifier) ||
|
||||
// Geo must not reach Cesium through its workspace packages either.
|
||||
(label === 'geo' && /^@cesium\//.test(specifier)) ||
|
||||
// Neither engine reaches the app clock.
|
||||
(label === 'time' &&
|
||||
(cesiumPackage.test(specifier) || navaraPackage.test(specifier)))
|
||||
)
|
||||
report(
|
||||
file,
|
||||
@@ -217,6 +282,51 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
visit(start);
|
||||
}
|
||||
// The globe contract reaches no engine, directly or through helpers; each
|
||||
// engine implementation imports only its own engine's packages.
|
||||
const globeSeen = new Set();
|
||||
function globe(file) {
|
||||
if (globeSeen.has(file) || !code.test(file)) return;
|
||||
globeSeen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external) {
|
||||
if (cesiumPackage.test(specifier) || navaraPackage.test(specifier))
|
||||
report(file, `globe contract reaches engine dependency: ${specifier}`);
|
||||
}
|
||||
for (const to of info.edges) {
|
||||
if (globeEngine(to))
|
||||
report(file, `globe contract imports engine implementation: ${to}`);
|
||||
else globe(to);
|
||||
}
|
||||
}
|
||||
for (const file of files.filter(globeContract)) globe(file);
|
||||
// Each engine graph stays on its own engine, through helpers too.
|
||||
for (const owner of ['cesium', 'navara']) {
|
||||
const navara = owner === 'navara';
|
||||
const label = navara ? 'Navara globe engine' : 'Cesium globe engine';
|
||||
const foreignPackage = navara ? cesiumPackage : navaraPackage;
|
||||
const foreignEngine = navara ? 'src/globe/cesium/' : 'src/globe/navara/';
|
||||
const seen = new Set();
|
||||
const visit = (file) => {
|
||||
if (seen.has(file) || !code.test(file)) return;
|
||||
seen.add(file);
|
||||
const info = load(file);
|
||||
if (!info) return;
|
||||
for (const specifier of info.external)
|
||||
if (foreignPackage.test(specifier))
|
||||
report(file, `${label} reaches ${specifier}`);
|
||||
for (const to of info.edges) {
|
||||
if (to.startsWith(foreignEngine))
|
||||
report(file, `${label} reaches ${to}`);
|
||||
else visit(to);
|
||||
}
|
||||
};
|
||||
for (const start of files.filter((file) =>
|
||||
file.startsWith(`src/globe/${owner}/`),
|
||||
))
|
||||
visit(start);
|
||||
}
|
||||
// Common voice controls may use their DOM view, but never a protocol implementation.
|
||||
const voiceSeen = new Set();
|
||||
function voice(file) {
|
||||
@@ -231,8 +341,17 @@ export function checkImportDirections(root) {
|
||||
}
|
||||
if (existsSync(path.join(root, 'src/voice/sessionCommands.js')))
|
||||
voice('src/voice/sessionCommands.js');
|
||||
for (const item of engineImportExceptions) {
|
||||
if (!usedExceptions.has(item))
|
||||
report(item.file, `Unused engine import exception: ${item.package}`);
|
||||
}
|
||||
if (errors.size) throw new Error([...errors].join('\n'));
|
||||
return { modules: files.length, portableEntries: roots.size };
|
||||
return {
|
||||
modules: files.length,
|
||||
portableEntries: roots.size,
|
||||
engineImportExceptionCount: usedExceptions.size,
|
||||
engineImportExceptions: [...usedExceptions],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The size budget of the Cesium-mode main chunk (run after `npm run build`):
|
||||
* gzip-9 of the entry chunk the built page loads (from the Vite manifest, or
|
||||
* the module script dist/index.html loads) may exceed the baseline in
|
||||
* scripts/main-chunk-baseline.json by at most its allowance. What the initial
|
||||
* chunks may hold is the build's own check (build/initialChunkGuard.js).
|
||||
*
|
||||
* Prints the size. `--json` prints the whole result; `--dist=DIR` reads
|
||||
* another build.
|
||||
*
|
||||
* npm run build && npm run check:main-chunk
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { entryFromHtml, entryFromManifest } from '../src/tooling/mainChunk.js';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** The verdict for a main-chunk size against the baseline. */
|
||||
export function checkMainChunk(
|
||||
gzipBytes,
|
||||
{ gzipBytes: baseline, allowanceBytes },
|
||||
) {
|
||||
const limit = baseline + allowanceBytes;
|
||||
const growth = gzipBytes - baseline;
|
||||
return {
|
||||
ok: gzipBytes <= limit,
|
||||
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* gzip-9 size of the main chunk, with its name: the entry the built page
|
||||
* loads (`entry`, else found from the Vite manifest or dist/index.html).
|
||||
*/
|
||||
export function measureMainChunk(distDir, { entry = null } = {}) {
|
||||
const file = entry ?? findEntry(distDir).entry;
|
||||
const bytes = fs.readFileSync(path.join(distDir, file));
|
||||
return {
|
||||
name: path.basename(file),
|
||||
gzipBytes: gzipSync(bytes, { level: 9 }).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry chunk (relative to dist) and how it was found. */
|
||||
function findEntry(dist) {
|
||||
const manifestPath = path.join(dist, '.vite', 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const entry = entryFromManifest(
|
||||
JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
|
||||
);
|
||||
if (entry) return { entry, via: 'manifest' };
|
||||
}
|
||||
const htmlPath = path.join(dist, 'index.html');
|
||||
if (!fs.existsSync(htmlPath))
|
||||
throw new Error(`No build at ${dist}: run npm run build first`);
|
||||
const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8'));
|
||||
if (!entry) throw new Error(`${htmlPath} loads no module script`);
|
||||
return { entry, via: 'index.html' };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const option = (name) =>
|
||||
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
|
||||
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
|
||||
const { entry, via } = findEntry(dist);
|
||||
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
|
||||
let budget = null;
|
||||
if (fs.existsSync(baselinePath)) {
|
||||
const measured = measureMainChunk(dist, { entry });
|
||||
budget = {
|
||||
...measured,
|
||||
...checkMainChunk(
|
||||
measured.gzipBytes,
|
||||
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
|
||||
),
|
||||
};
|
||||
}
|
||||
const ok = budget?.ok ?? true;
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log(JSON.stringify({ entry, via, budget, ok }, null, 2));
|
||||
} else {
|
||||
console.log(`[check:main-chunk] entry ${entry} (via ${via})`);
|
||||
console.log(
|
||||
budget
|
||||
? `[check:main-chunk] ${budget.name}: ${budget.message}`
|
||||
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
|
||||
);
|
||||
}
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
main();
|
||||
@@ -11,7 +11,9 @@ export async function checkPackageBoundaries(root) {
|
||||
const readJson = async (name) =>
|
||||
JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
||||
const pkg = await readJson('package.json');
|
||||
const groups = await readJson('scripts/package-boundaries.json');
|
||||
const { engineImportExceptions, ...groups } = await readJson(
|
||||
'scripts/package-boundaries.json',
|
||||
);
|
||||
const declaredExports = Object.keys(pkg.exports || {}).sort();
|
||||
const classifiedExports = Object.values(groups)
|
||||
.flatMap((group) => group.exports)
|
||||
@@ -37,7 +39,11 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Invalid package boundary: ${name}`);
|
||||
}
|
||||
// A module entry ending in "/" owns every file under that directory.
|
||||
const allowed = new Set();
|
||||
const allowedDirectories = [];
|
||||
const owns = (id) =>
|
||||
allowed.has(id) || allowedDirectories.some((dir) => id.startsWith(dir));
|
||||
for (const module of group.modules) {
|
||||
if (
|
||||
typeof module !== 'string' ||
|
||||
@@ -56,7 +62,11 @@ export async function checkPackageBoundaries(root) {
|
||||
) {
|
||||
throw new Error(`Boundary module escapes repository: ${name}`);
|
||||
}
|
||||
allowed.add(normalizePath(resolved));
|
||||
if (module.endsWith('/')) {
|
||||
allowedDirectories.push(`${normalizePath(resolved)}/`);
|
||||
} else {
|
||||
allowed.add(normalizePath(resolved));
|
||||
}
|
||||
}
|
||||
for (const external of group.external) {
|
||||
if (
|
||||
@@ -82,7 +92,7 @@ export async function checkPackageBoundaries(root) {
|
||||
if (
|
||||
typeof target !== 'string' ||
|
||||
!target.startsWith('./') ||
|
||||
!allowed.has(normalizePath(path.resolve(root, target)))
|
||||
!owns(normalizePath(path.resolve(root, target)))
|
||||
) {
|
||||
throw new Error(`Export must point to an owned module: ${key}`);
|
||||
}
|
||||
@@ -109,7 +119,7 @@ export async function checkPackageBoundaries(root) {
|
||||
// template blocks belong to the component file itself.
|
||||
if (info.id.startsWith('\0')) return;
|
||||
const id = info.id.split('?')[0];
|
||||
if (!allowed.has(id)) {
|
||||
if (!owns(id)) {
|
||||
throw new Error(
|
||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||
);
|
||||
|
||||
@@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then
|
||||
fi
|
||||
|
||||
if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \
|
||||
! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then
|
||||
echo "error: CCTV layer not wired in src/standalone/catalog.js"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 0,
|
||||
"navaraLayerRendererLines": 0,
|
||||
"cesiumImportFiles": 3,
|
||||
"viewerLines": 750,
|
||||
"navaraKindChecks": 1,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 3,
|
||||
"groundEffectPasses": 0,
|
||||
"cesiumSdkLoads": 3,
|
||||
"screenSpaceHandlerFiles": 2,
|
||||
"engineNameLines": 27
|
||||
},
|
||||
"measuredAt": "One selection path and test level"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ENGINE_COUPLING_METRICS,
|
||||
couplingGrowth,
|
||||
measureEngineCoupling,
|
||||
readBaseline,
|
||||
} from './report-engine-coupling.mjs';
|
||||
|
||||
test('engine coupling does not grow past the baseline', () => {
|
||||
const growth = couplingGrowth(
|
||||
measureEngineCoupling(),
|
||||
readBaseline().metrics,
|
||||
);
|
||||
assert.deepEqual(
|
||||
growth,
|
||||
[],
|
||||
'Code outside the globe engines grew more engine-specific. Draw through ' +
|
||||
'the globe contract instead; only a deliberate exception may raise ' +
|
||||
'scripts/engine-coupling-baseline.json (--write-baseline).',
|
||||
);
|
||||
});
|
||||
|
||||
test('the baseline names every metric', () => {
|
||||
const { metrics } = readBaseline();
|
||||
assert.deepEqual(
|
||||
Object.keys(metrics).sort(),
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => id).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a metric that grows is reported, one that falls is not', () => {
|
||||
const baseline = Object.fromEntries(
|
||||
ENGINE_COUPLING_METRICS.map(({ id }) => [id, 10]),
|
||||
);
|
||||
const measured = { ...baseline, viewerLines: 11, navaraKindChecks: 3 };
|
||||
assert.deepEqual(
|
||||
couplingGrowth(measured, baseline).map(({ id }) => id),
|
||||
['viewerLines'],
|
||||
);
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
// Recorded journeys cover both a dense neighbourhood and the wider Austin view.
|
||||
export const cameras = [
|
||||
...Array.from({ length: 225 }, (_, i) => ({
|
||||
latitude: 30.262 + (Math.floor(i / 15) - 7) * 0.003,
|
||||
longitude: -97.7431 + ((i % 15) - 7) * 0.003,
|
||||
})),
|
||||
...Array.from({ length: 100 }, (_, i) => ({
|
||||
latitude: 30.308 + (Math.floor(i / 10) - 4.5) * 0.001,
|
||||
longitude: -97.77 + ((i % 10) - 4.5) * 0.001,
|
||||
})),
|
||||
].map((position, i) => ({
|
||||
...position,
|
||||
osmId: 9000000 + i,
|
||||
operator: i % 3 ? 'Deterministic fixture' : 'Cámara pública',
|
||||
brand: 'Fixture camera',
|
||||
cameraType: 'fixed',
|
||||
direction: i % 5 ? String((i * 37) % 360) : '',
|
||||
}));
|
||||
|
||||
export function cameraTile(z, x, y, records = cameras) {
|
||||
const extent = 4096;
|
||||
const scale = 2 ** z;
|
||||
const rows = records.flatMap((record) => {
|
||||
const latitude = (record.latitude * Math.PI) / 180;
|
||||
const tx = ((record.longitude + 180) / 360) * scale;
|
||||
const ty = ((1 - Math.asinh(Math.tan(latitude)) / Math.PI) / 2) * scale;
|
||||
if (Math.floor(tx) !== x || Math.floor(ty) !== y) return [];
|
||||
return [
|
||||
{
|
||||
record,
|
||||
x: Math.round((tx - x) * extent),
|
||||
y: Math.round((ty - y) * extent),
|
||||
},
|
||||
];
|
||||
});
|
||||
const keys = [
|
||||
'osmType',
|
||||
'osmId',
|
||||
'operator',
|
||||
'brand',
|
||||
'cameraType',
|
||||
'direction',
|
||||
];
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'cameras');
|
||||
layer.writeVarintField(5, extent);
|
||||
for (const key of keys) layer.writeStringField(3, key);
|
||||
rows.forEach(({ record, x, y }, index) => {
|
||||
for (const key of keys)
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) =>
|
||||
value.writeStringField(
|
||||
1,
|
||||
key === 'osmType' ? 'node' : String(record[key]),
|
||||
),
|
||||
null,
|
||||
);
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(1, record.osmId);
|
||||
feature.writePackedVarint(
|
||||
2,
|
||||
keys.flatMap((_, i) => [i, index * keys.length + i]),
|
||||
);
|
||||
feature.writeVarintField(3, 1);
|
||||
feature.writePackedVarint(4, [9, x * 2, y * 2]);
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
},
|
||||
null,
|
||||
);
|
||||
return Buffer.from(writer.finish());
|
||||
}
|
||||
|
||||
/** Real TileJSON/MVT transport; no replacement of the layer or source parser. */
|
||||
export async function installAlprFixture(page) {
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
if (request.isInterceptResolutionHandled()) return;
|
||||
const url = new URL(request.url());
|
||||
// The journey also asks for nearby-place labels while moving. Pin that
|
||||
// auxiliary service; these runs do not test Google Places availability.
|
||||
if (
|
||||
['localhost', '127.0.0.1'].includes(url.hostname) &&
|
||||
url.pathname === '/api/google/nearby-places'
|
||||
) {
|
||||
void request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ places: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.hostname !== 'tiles.dontgetflocked.com') {
|
||||
void request.continue();
|
||||
return;
|
||||
}
|
||||
const headers = { 'access-control-allow-origin': '*' };
|
||||
if (url.pathname.endsWith('.json')) {
|
||||
const country = url.pathname.includes('-ca-') ? 'ca' : 'us';
|
||||
void request.respond({
|
||||
status: 200,
|
||||
headers,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
tiles: [
|
||||
`https://tiles.dontgetflocked.com/cameras-${country}-hourly/{z}/{x}/{y}.mvt`,
|
||||
],
|
||||
bounds: country === 'ca' ? [-124, 42, -63, 54] : [-160, 17, -64, 59],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const match = url.pathname.match(/\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
void request.respond({
|
||||
status: match ? 200 : 404,
|
||||
headers,
|
||||
contentType: 'application/x-protobuf',
|
||||
body: match ? cameraTile(...match.slice(1).map(Number)) : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Transport fixtures are parsed by the real CCTV catalog and media owners.
|
||||
export const cameras = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `fixture-cctv-${String(i).padStart(3, '0')}`,
|
||||
name: i === 12 ? 'Cámara pública — Congress' : `Fixture camera ${i}`,
|
||||
cityId: 'austin',
|
||||
city: 'Austin',
|
||||
lat: 30.2672 + (Math.floor(i / 5) - 2) * 0.0018,
|
||||
lon: -97.7431 + ((i % 5) - 2) * 0.0018,
|
||||
headingDeg: (i * 37) % 360,
|
||||
fovDeg: i % 2 ? 44 : 56,
|
||||
rangeM: i % 2 ? 145 : 210,
|
||||
mountHeightM: i % 2 ? 8 : 10,
|
||||
pitchDeg: i % 2 ? -18 : -24,
|
||||
groundElevationM: 150,
|
||||
headingConfidence: i % 3 ? 'medium' : 'low',
|
||||
sourceKind: 'configured',
|
||||
feedType: 'image',
|
||||
provider: 'Deterministic fixture',
|
||||
url: 'https://cctv-fixture.invalid/frame.png',
|
||||
}));
|
||||
|
||||
export async function cameraFrame() {
|
||||
const width = 320,
|
||||
height = 180;
|
||||
const pixels = Buffer.alloc(width * height * 3);
|
||||
const colors = [
|
||||
[240, 45, 70],
|
||||
[35, 220, 90],
|
||||
[40, 90, 235],
|
||||
[245, 210, 30],
|
||||
];
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const color =
|
||||
colors[(x >= width / 2 ? 1 : 0) + (y >= height / 2 ? 2 : 0)];
|
||||
const grid = x % 40 < 2 || y % 30 < 2;
|
||||
const at = (y * width + x) * 3;
|
||||
for (let c = 0; c < 3; c++) pixels[at + c] = grid ? 15 : color[c];
|
||||
}
|
||||
}
|
||||
return sharp(pixels, { raw: { width, height, channels: 3 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export async function installCctvFixture(page, { video } = {}) {
|
||||
const frame = await cameraFrame();
|
||||
const catalog = cameras.map((camera) => ({ ...camera }));
|
||||
if (video)
|
||||
catalog.push({
|
||||
...cameras[0],
|
||||
id: 'fixture-cctv-video',
|
||||
name: 'Fixture moving video',
|
||||
lat: 30.2718,
|
||||
lon: -97.744,
|
||||
feedType: 'video',
|
||||
url: 'https://cctv-fixture.invalid/camera.mp4',
|
||||
});
|
||||
const ids = new Set(catalog.map((camera) => camera.id));
|
||||
// Global Puppeteer interception can stall Cesium's module workers in
|
||||
// Chromium 152. Intercept only fixture API routes on the page's CDP session.
|
||||
const session = await page.createCDPSession();
|
||||
let disposed = false;
|
||||
const routes = [
|
||||
'/api/cctv/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
'/api/google/nearby-places*',
|
||||
];
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
routes.map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
const respond = (
|
||||
requestId,
|
||||
{ status, contentType, headers = {}, body = '' },
|
||||
) =>
|
||||
session.send('Fetch.fulfillRequest', {
|
||||
requestId,
|
||||
responseCode: status,
|
||||
responseHeaders: Object.entries({
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
...headers,
|
||||
}).map(([name, value]) => ({ name, value: String(value) })),
|
||||
body: Buffer.from(body).toString('base64'),
|
||||
});
|
||||
const handle = (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||
return request.continue();
|
||||
}
|
||||
const json = (body) =>
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const auxiliary = {
|
||||
'/api/flights': { states: [] },
|
||||
'/api/military': { ac: [] },
|
||||
'/api/vessels': { connected: false, vessels: [] },
|
||||
};
|
||||
if (auxiliary[url.pathname]) {
|
||||
return json(auxiliary[url.pathname]);
|
||||
}
|
||||
if (url.pathname === '/api/cctv/sources') {
|
||||
return json({ sources: catalog });
|
||||
}
|
||||
if (url.pathname === '/api/cctv/health') {
|
||||
return json({
|
||||
cameras: catalog.map(({ id, feedType }) => ({
|
||||
id,
|
||||
status: 'online',
|
||||
sourceKind: feedType,
|
||||
label: 'Deterministic fixture',
|
||||
updatedAt: Date.now(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
const media = url.pathname.match(/^\/api\/cctv\/(frame|media)\/([^/]+)$/);
|
||||
if (media) {
|
||||
const id = decodeURIComponent(media[2]);
|
||||
if (
|
||||
!ids.has(id) ||
|
||||
(media[1] === 'media' && (id !== 'fixture-cctv-video' || !video))
|
||||
) {
|
||||
return request.respond({ status: 404, body: 'Unknown fixture camera' });
|
||||
}
|
||||
if (media[1] === 'frame') {
|
||||
return request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: frame,
|
||||
});
|
||||
}
|
||||
const range = request.headers().range?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const end =
|
||||
range && range[2]
|
||||
? Math.min(Number(range[2]), video.length - 1)
|
||||
: video.length - 1;
|
||||
if (start > end || start >= video.length) {
|
||||
return request.respond({
|
||||
status: 416,
|
||||
headers: { 'content-range': `bytes */${video.length}` },
|
||||
});
|
||||
}
|
||||
return request.respond({
|
||||
status: range ? 206 : 200,
|
||||
contentType: 'video/mp4',
|
||||
headers: {
|
||||
'accept-ranges': 'bytes',
|
||||
...(range
|
||||
? { 'content-range': `bytes ${start}-${end}/${video.length}` }
|
||||
: {}),
|
||||
},
|
||||
body: video.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/google/nearby-places') {
|
||||
return json({ places: [] });
|
||||
}
|
||||
return request.continue();
|
||||
};
|
||||
session.on('Fetch.requestPaused', (event) => {
|
||||
const request = {
|
||||
url: () => event.request.url,
|
||||
headers: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(event.request.headers).map(([name, value]) => [
|
||||
name.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
),
|
||||
respond: (response) => respond(event.requestId, response),
|
||||
continue: () =>
|
||||
session.send('Fetch.continueRequest', { requestId: event.requestId }),
|
||||
};
|
||||
Promise.resolve()
|
||||
.then(() => handle(request))
|
||||
.catch((error) => {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
});
|
||||
});
|
||||
return {
|
||||
cameras: catalog,
|
||||
frame,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const ORIGIN = { lat: 42.3554, lon: -71.0605 };
|
||||
const grid = (i, cols, spacing) => ({
|
||||
lat: ORIGIN.lat + (Math.floor(i / cols) - cols / 2) * spacing,
|
||||
lon: ORIGIN.lon + ((i % cols) - cols / 2) * spacing,
|
||||
});
|
||||
const bikes = Array.from({ length: 200 }, (_, i) => ({
|
||||
station_id: `fixture-bike-${i}`,
|
||||
name: `Fixture station ${i}`,
|
||||
capacity: 20,
|
||||
...grid(i, 15, 0.0015),
|
||||
}));
|
||||
export const combinedPayloads = { bikes };
|
||||
|
||||
/** Bikeshare and startup feed fixtures shared by both engines. */
|
||||
export async function installCombinedFixture(page) {
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['127.0.0.1', 'localhost'].map((host) => ({
|
||||
urlPattern: `http://${host}:*/api/*`,
|
||||
})),
|
||||
});
|
||||
let disposed = false;
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const u = new URL(event.request.url);
|
||||
let body;
|
||||
const p = u.pathname;
|
||||
if (p.startsWith('/api/gbfs/'))
|
||||
body = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
stations: decodeURIComponent(u.href).includes('station_information')
|
||||
? bikes
|
||||
: bikes.map(({ station_id }) => ({
|
||||
station_id,
|
||||
num_bikes_available: 7,
|
||||
num_docks_available: 13,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
else if (p === '/api/flights') body = { states: [] };
|
||||
else if (p === '/api/military') body = { ac: [] };
|
||||
else if (p === '/api/vessels') body = { connected: false, vessels: [] };
|
||||
else if (p === '/api/google/nearby-places') body = { places: [] };
|
||||
else if (p === '/api/setup/status') body = { ready: true };
|
||||
else if (p === '/api/terrain/heights')
|
||||
body = {
|
||||
results: (u.searchParams.get('points') || '')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
.map((point) => {
|
||||
const [lon, lat] = point.split(',').map(Number);
|
||||
return { lon, lat, elevation: 20, geoid: 0, ellipsoid: 20 };
|
||||
}),
|
||||
};
|
||||
if (body === undefined) {
|
||||
await session.send('Fetch.continueRequest', {
|
||||
requestId: event.requestId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
requests.push(p);
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [
|
||||
{ name: 'Content-Type', value: 'application/json' },
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||
{ name: 'Data-Present', value: 'true' },
|
||||
],
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!disposed &&
|
||||
!page.isClosed() &&
|
||||
!String(error.message).includes('Invalid InterceptionId')
|
||||
)
|
||||
page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Real /api/firms envelope; fixed source receipt and acquisition clocks.
|
||||
export const FIRMS_EPOCH_MS = Date.UTC(2026, 9, 16, 12);
|
||||
export const FIRMS_VALID_COUNT = 371;
|
||||
|
||||
export function firmsFixture() {
|
||||
const fires = [];
|
||||
const regions = [
|
||||
[37.77, -122.42],
|
||||
[30.27, -97.74],
|
||||
[61.2, -149.9],
|
||||
];
|
||||
for (const [region, [latitude, longitude]] of regions.entries()) {
|
||||
for (let row = -5; row <= 5; row++) {
|
||||
for (let column = -5; column <= 5; column++) {
|
||||
const index = fires.length;
|
||||
fires.push({
|
||||
lat: latitude + row * 0.12,
|
||||
lon: longitude + column * 0.12,
|
||||
frp:
|
||||
region === 0 && row === 0 && column === 0 ? 999 : 5 + (index % 120),
|
||||
confidence: ['low', 'nominal', 'high', 85][index % 4],
|
||||
brightness: 280 + (index % 90),
|
||||
daynight: index % 2 ? 'N' : 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: String(800 + (index % 60)),
|
||||
instrument: index % 4 === 3 ? 'MODIS' : 'VIIRS',
|
||||
satellite: index % 4 === 3 ? 'Terra' : 'NOAA-20',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [lat, lon] of [
|
||||
[10, 179.7],
|
||||
[10, -179.7],
|
||||
[89.5, 45],
|
||||
[-89.5, -135],
|
||||
[0.2, 179.8],
|
||||
[-0.2, -179.8],
|
||||
[70, 179.5],
|
||||
[-70, -179.5],
|
||||
]) {
|
||||
fires.push({
|
||||
lat,
|
||||
lon,
|
||||
frp: 30,
|
||||
confidence: 'high',
|
||||
brightness: 320,
|
||||
daynight: 'D',
|
||||
acqDate: '2026-10-16',
|
||||
acqTime: '930',
|
||||
instrument: 'VIIRS',
|
||||
satellite: 'NOAA-20',
|
||||
});
|
||||
}
|
||||
fires.push({ lat: 'invalid', lon: 0 }, { lat: 0, lon: 'invalid' });
|
||||
return { fires, fetchedAt: FIRMS_EPOCH_MS - 120000, stale: false };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Shared 750-station directory for legacy and both-engine radio QA.
|
||||
const tags = [
|
||||
'news',
|
||||
'talk',
|
||||
'weather,emergency',
|
||||
'public safety,scanner',
|
||||
'aviation,atc',
|
||||
'marine,maritime',
|
||||
'traffic,transit',
|
||||
'music,jazz',
|
||||
'music,rock',
|
||||
'community',
|
||||
];
|
||||
export const stations = Array.from({ length: 750 }, (_, index) => {
|
||||
const row = Math.floor(index / 30);
|
||||
const column = index % 30;
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
|
||||
name:
|
||||
index === 360
|
||||
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
|
||||
: index === 390
|
||||
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
|
||||
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
|
||||
lat: -72 + row * 6,
|
||||
lon: -174 + column * 12,
|
||||
streamUrl: `https://audio.example.test/${index}.mp3`,
|
||||
homepage: `https://station.example.test/${index}`,
|
||||
tags: tags[index % tags.length].split(','),
|
||||
languages: index === 360 ? ['Spanish'] : ['English'],
|
||||
state: `Region ${row + 1}`,
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: index % 2 ? 'AAC' : 'MP3',
|
||||
bitrate: 128,
|
||||
};
|
||||
});
|
||||
|
||||
export async function installRadioFixture(page) {
|
||||
await page.evaluateOnNewDocument((stations) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__qaRadio = {
|
||||
stations,
|
||||
generation: 1,
|
||||
plays: [],
|
||||
delay: 0,
|
||||
response: 'normal',
|
||||
pending: null,
|
||||
};
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
window.qaPlayer = this;
|
||||
window.__qaRadio.plays.push(this.src);
|
||||
if (window.__qaRadio.delayPlay) {
|
||||
window.__qaRadio.delayPlay = false;
|
||||
return new Promise((resolve) => {
|
||||
window.__qaRadio.releasePlay = () => {
|
||||
this.dispatchEvent(new Event('playing'));
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('playing')));
|
||||
return Promise.resolve();
|
||||
};
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const address = String(
|
||||
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||
);
|
||||
if (address.includes('/api/radio/stations')) {
|
||||
const qa = window.__qaRadio;
|
||||
qa.reads = (qa.reads || 0) + 1;
|
||||
const response = {
|
||||
stations: qa.response === 'empty' ? [] : qa.stations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: qa.generation,
|
||||
catalogInstance: 'fixture-radio-fixture',
|
||||
};
|
||||
const mode = qa.response;
|
||||
const delay = qa.delay;
|
||||
qa.delay = 0;
|
||||
if (delay)
|
||||
await new Promise((resolve) => {
|
||||
qa.pending = resolve;
|
||||
});
|
||||
init?.signal?.throwIfAborted();
|
||||
return new Response(
|
||||
mode === 'failure' ? '{}' : JSON.stringify(response),
|
||||
{
|
||||
status: mode === 'failure' ? 503 : 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (address.includes('/api/radio/click/'))
|
||||
return new Response(null, { status: 204 });
|
||||
if (address.includes('/api/flights'))
|
||||
return new Response('{"states":[]}');
|
||||
if (address.includes('/api/military')) return new Response('{"ac":[]}');
|
||||
if (address.includes('/api/vessels'))
|
||||
return new Response('{"connected":false,"vessels":[]}');
|
||||
return fetch(input, init);
|
||||
};
|
||||
}, stations);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export const TRANSIT_EPOCH = 1_793_232_000_000;
|
||||
const routes = ['1', 'Green-B', 'Red', 'CR-Fitchburg', 'Boat-F1', ''];
|
||||
export function transitSnapshot(step = 8) {
|
||||
const fetchedAt = TRANSIT_EPOCH - 135000 + step * 15000;
|
||||
return {
|
||||
fetchedAt,
|
||||
vehicles: [
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `fixture-${i}`,
|
||||
routeId: routes[i % 6],
|
||||
tripId: `trip-${i}`,
|
||||
lat:
|
||||
42.355 +
|
||||
(Math.floor(i / 4) - 1) * 0.0017 +
|
||||
(i < 6 ? step * 0.00012 : 0),
|
||||
lon: -71.065 + ((i % 4) - 1.5) * 0.0017,
|
||||
timestamp: fetchedAt / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
bearing: 0,
|
||||
})),
|
||||
{
|
||||
id: 'stale',
|
||||
routeId: '1',
|
||||
tripId: 'old',
|
||||
lat: 42.355,
|
||||
lon: -71.065,
|
||||
timestamp: (TRANSIT_EPOCH - 900000) / 1000,
|
||||
timestampSource: 'vehicle',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
export async function installTransitFixture(page) {
|
||||
let step = 0,
|
||||
disposed = false;
|
||||
const requests = [];
|
||||
const session = await page.createCDPSession();
|
||||
await page.evaluateOnNewDocument((epoch) => {
|
||||
Date.now = () => epoch + performance.now();
|
||||
}, TRANSIT_EPOCH);
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: ['localhost', '127.0.0.1'].flatMap((host) =>
|
||||
[
|
||||
'/api/transit/*',
|
||||
'/api/flights*',
|
||||
'/api/military*',
|
||||
'/api/vessels*',
|
||||
].map((route) => ({ urlPattern: `http://${host}:*${route}` })),
|
||||
),
|
||||
});
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url);
|
||||
requests.push(url.pathname);
|
||||
let body;
|
||||
if (url.pathname.startsWith('/api/transit/vehicles/'))
|
||||
body = url.pathname.endsWith('/mbta')
|
||||
? transitSnapshot(step)
|
||||
: { fetchedAt: TRANSIT_EPOCH, vehicles: [] };
|
||||
else if (url.pathname.startsWith('/api/transit/trail/')) {
|
||||
const vehicleId = decodeURIComponent(url.pathname.split('/').at(-1));
|
||||
const record = transitSnapshot().vehicles.find(
|
||||
(row) => row.id === vehicleId,
|
||||
);
|
||||
body = {
|
||||
version: 1,
|
||||
feedId: 'mbta',
|
||||
vehicleId,
|
||||
truncated: false,
|
||||
epochs: [
|
||||
{ id: 1, trip: record.tripId, route: record.routeId, mode: 'bus' },
|
||||
],
|
||||
fixes: Array.from({ length: 9 }, (_, i) => {
|
||||
const snapshot = transitSnapshot(i),
|
||||
row = snapshot.vehicles.find((v) => v.id === vehicleId);
|
||||
return [snapshot.fetchedAt, row.lat, row.lon, 0, 1];
|
||||
}),
|
||||
};
|
||||
} else body = { states: [], ac: [], vessels: [], feeds: [] };
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: 200,
|
||||
responseHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
body: Buffer.from(JSON.stringify(body)).toString('base64'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!disposed && !page.isClosed()) page.emit('pageerror', error);
|
||||
}
|
||||
});
|
||||
return {
|
||||
requests,
|
||||
setStep(value) {
|
||||
step = value;
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
await session.detach();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Wildlife Sightings without the network: the app's own iNaturalist and
|
||||
// Acartia proxies (server/providers) answer the preview's /api routes in
|
||||
// this process, over upstream feeds faked here. Four sightings over the
|
||||
// Salish Sea: three iNaturalist observations (one with an obscured
|
||||
// location, drawn as its privacy cell) and one Acartia orca report.
|
||||
import { installInatProxy } from '../../server/providers/inaturalist/proxy.fixture.mjs';
|
||||
import { syntheticGrid } from '../../server/providers/inaturalist/grid.fixture.mjs';
|
||||
import { acartiaProxy } from '../../server/providers/acartia.js';
|
||||
|
||||
/** The view the scenario frames (straight down). */
|
||||
export const WILDLIFE_VIEW = Object.freeze({
|
||||
lat: 48.4,
|
||||
lon: -123.1,
|
||||
alt: 200000,
|
||||
});
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
// iNaturalist iconic taxon ids, as the UTFGrid carries them.
|
||||
const ICONIC_IDS = { Aves: 3, Mammalia: 40151, Plantae: 47126 };
|
||||
|
||||
/**
|
||||
* The sightings as the browser draws them (`lat`/`lon` after the proxy's
|
||||
* projection: an obscured record sits at the centre of its 0.2° cell, an
|
||||
* Acartia report is rounded to 0.01°), with what the upstream feeds send.
|
||||
*/
|
||||
export const WILDLIFE_SIGHTINGS = Object.freeze([
|
||||
{
|
||||
id: 'inat:910001',
|
||||
name: 'Bald Eagle',
|
||||
scientific: 'Haliaeetus leucocephalus',
|
||||
iconic: 'Aves',
|
||||
raw: [48.4, -123.1],
|
||||
lat: 48.4,
|
||||
lon: -123.1,
|
||||
},
|
||||
{
|
||||
id: 'inat:910002',
|
||||
name: "Douglas's Squirrel",
|
||||
scientific: 'Tamiasciurus douglasii',
|
||||
iconic: 'Mammalia',
|
||||
raw: [48.46, -122.92],
|
||||
lat: 48.46,
|
||||
lon: -122.92,
|
||||
},
|
||||
{
|
||||
id: 'inat:910003',
|
||||
name: 'Western Redcedar',
|
||||
scientific: 'Thuja plicata',
|
||||
iconic: 'Plantae',
|
||||
obscured: true,
|
||||
raw: [48.31, -123.29],
|
||||
lat: 48.3,
|
||||
lon: -123.3,
|
||||
cell: { south: 48.2, west: -123.4, north: 48.4, east: -123.2 },
|
||||
},
|
||||
{
|
||||
id: 'acartia:qa-orca-1',
|
||||
name: 'Orca',
|
||||
origin: 'acartia',
|
||||
raw: [48.5234, -123.2167],
|
||||
lat: 48.52,
|
||||
lon: -123.22,
|
||||
},
|
||||
]);
|
||||
|
||||
const inat = WILDLIFE_SIGHTINGS.filter((s) => s.id.startsWith('inat:'));
|
||||
const acartia = WILDLIFE_SIGHTINGS.filter((s) => s.origin === 'acartia');
|
||||
|
||||
/** An API v2 observation, with the fields the proxy asks for. */
|
||||
function observation(sighting, nowMs) {
|
||||
const id = Number(sighting.id.slice(5));
|
||||
const [lat, lon] = sighting.raw;
|
||||
const time = new Date(nowMs - 3 * HOUR_MS);
|
||||
return {
|
||||
id,
|
||||
uri: `https://www.inaturalist.org/observations/${id}`,
|
||||
location: `${lat},${lon}`,
|
||||
geojson: { type: 'Point', coordinates: [lon, lat] },
|
||||
geoprivacy: sighting.obscured ? 'obscured' : null,
|
||||
taxon_geoprivacy: null,
|
||||
obscured: sighting.obscured === true,
|
||||
quality_grade: 'research',
|
||||
observed_on: time.toISOString().slice(0, 10),
|
||||
time_observed_at: time.toISOString(),
|
||||
faves_count: 1,
|
||||
num_identification_agreements: 2,
|
||||
// The feed asks for observations with photos; these carry no open
|
||||
// licence, so no thumbnail is fetched.
|
||||
photos: [
|
||||
{
|
||||
url: `https://inaturalist-open-data.s3.amazonaws.com/photos/${id}/square.jpeg`,
|
||||
attribution: '(c) QA observer, all rights reserved',
|
||||
license_code: null,
|
||||
},
|
||||
],
|
||||
taxon: {
|
||||
name: sighting.scientific,
|
||||
preferred_common_name: sighting.name,
|
||||
iconic_taxon_name: sighting.iconic,
|
||||
threatened: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Web Mercator tile coordinates of a point at zoom z (fractional). */
|
||||
function tileOf(lat, lon, z) {
|
||||
const count = 2 ** z;
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
return {
|
||||
x: ((lon + 180) / 360) * count,
|
||||
y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The faked upstream feeds: iNaturalist observations (filtered by the
|
||||
* request's box), its UTFGrid density tiles, and the Acartia snapshot.
|
||||
* `calls` counts what each upstream was asked.
|
||||
*/
|
||||
export function wildlifeUpstream({ now = () => Date.now() } = {}) {
|
||||
const calls = { observations: 0, grid: 0, acartia: 0 };
|
||||
async function fetchImpl(href) {
|
||||
const url = new URL(href);
|
||||
if (url.host === 'acartia.io') {
|
||||
calls.acartia++;
|
||||
const created = new Date(now() - 2 * HOUR_MS)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace('T', ' ');
|
||||
return Response.json(
|
||||
acartia.map((s) => ({
|
||||
entry_id: s.id.slice(8),
|
||||
latitude: s.raw[0],
|
||||
longitude: s.raw[1],
|
||||
created,
|
||||
type: s.name,
|
||||
no_sighted: '3',
|
||||
data_source_name: 'QA Whale Network',
|
||||
data_source_entity: 'Community reports',
|
||||
trusted: true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (url.host !== 'api.inaturalist.org')
|
||||
throw Error('unexpected upstream ' + url.host);
|
||||
const grid = /^\/v1\/grid\/(\d+)\/(\d+)\/(\d+)\.grid\.json$/.exec(
|
||||
url.pathname,
|
||||
);
|
||||
if (grid) {
|
||||
calls.grid++;
|
||||
const [z, x, y] = grid.slice(1).map(Number);
|
||||
const cells = [];
|
||||
for (const s of inat) {
|
||||
const at = tileOf(s.raw[0], s.raw[1], z);
|
||||
if (Math.floor(at.x) !== x || Math.floor(at.y) !== y) continue;
|
||||
cells.push({
|
||||
row: Math.min(63, Math.floor((at.y - y) * 64)),
|
||||
column: Math.min(63, Math.floor((at.x - x) * 64)),
|
||||
count: 4,
|
||||
iconic: ICONIC_IDS[s.iconic],
|
||||
});
|
||||
}
|
||||
return Response.json(syntheticGrid({ z, x, y, cells }));
|
||||
}
|
||||
if (url.pathname === '/v2/observations') {
|
||||
calls.observations++;
|
||||
const box = ['swlat', 'swlng', 'nelat', 'nelng'].map((key) =>
|
||||
url.searchParams.has(key) ? Number(url.searchParams.get(key)) : null,
|
||||
);
|
||||
const inside = ({ raw: [lat, lon] }) =>
|
||||
box[0] === null ||
|
||||
(lat >= box[0] && lat <= box[2] && lon >= box[1] && lon <= box[3]);
|
||||
const results = inat.filter(inside).map((s) => observation(s, now()));
|
||||
return Response.json({
|
||||
total_results: results.length,
|
||||
page: 1,
|
||||
per_page: 200,
|
||||
results,
|
||||
});
|
||||
}
|
||||
throw Error('unexpected upstream path ' + url.pathname);
|
||||
}
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
/** Mount an /api/acartia proxy over synthetic HTTP objects. */
|
||||
function installAcartiaProxy(options) {
|
||||
let handler;
|
||||
acartiaProxy({ warn() {}, ...options }).configurePreviewServer({
|
||||
middlewares: {
|
||||
use(mount, callback) {
|
||||
if (mount !== '/api/acartia') throw Error('acartia mount ' + mount);
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url, { headers = {} } = {}) => {
|
||||
const res = {
|
||||
headers: {},
|
||||
writeHead(status, values) {
|
||||
this.status = status;
|
||||
Object.assign(this.headers, values);
|
||||
},
|
||||
setHeader(key, value) {
|
||||
this.headers[key] = value;
|
||||
},
|
||||
end(value) {
|
||||
this.status ??= this.statusCode;
|
||||
this.body = JSON.parse(value);
|
||||
},
|
||||
};
|
||||
await handler(
|
||||
{ url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } },
|
||||
res,
|
||||
);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict-harness routes for the preview's wildlife API: each request is
|
||||
* answered by the app's own proxy in this process (it never reaches the
|
||||
* preview, so the preview never calls the real providers). `asked` counts
|
||||
* the browser's requests per route.
|
||||
*/
|
||||
export function wildlifeRoutes(origin, options = {}) {
|
||||
const upstream = wildlifeUpstream(options);
|
||||
const proxies = {
|
||||
'/api/inaturalist': installInatProxy('/observations', {
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
now: options.now ?? (() => Date.now()),
|
||||
}),
|
||||
'/api/acartia': installAcartiaProxy({
|
||||
fetchImpl: upstream.fetchImpl,
|
||||
now: options.now ?? (() => Date.now()),
|
||||
}),
|
||||
};
|
||||
const asked = { observations: 0, grid: 0, acartia: 0 };
|
||||
const route = (mount) => ({
|
||||
pattern: `${origin}${mount}/*`,
|
||||
async respond(request) {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname.slice(mount.length);
|
||||
asked[mount === '/api/acartia' ? 'acartia' : path.slice(1)]++;
|
||||
const headers = { host: url.host };
|
||||
for (const [name, value] of Object.entries(request.headers ?? {}))
|
||||
headers[name.toLowerCase()] = value;
|
||||
const res = await proxies[mount](path + url.search, { headers });
|
||||
return {
|
||||
status: res.status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(res.body),
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
routes: [route('/api/inaturalist'), route('/api/acartia')],
|
||||
asked,
|
||||
upstream: upstream.calls,
|
||||
};
|
||||
}
|
||||
+370
-29
@@ -1,4 +1,7 @@
|
||||
[
|
||||
"src/data/sceneServices.js",
|
||||
"src/data/sceneServices.test.mjs",
|
||||
"src/data/trackingGesture.js",
|
||||
".prettierrc.json",
|
||||
"package.json",
|
||||
"scripts/format-scope.json",
|
||||
@@ -56,11 +59,11 @@
|
||||
"src/app/application.test.mjs",
|
||||
"src/app/mapLifecycle.test.mjs",
|
||||
"src/app/startupCamera.test.mjs",
|
||||
"src/app/viewer.js",
|
||||
"src/app/zoomOutGuard.test.mjs",
|
||||
"src/globe/cesium/viewer.js",
|
||||
"src/globe/cesium/zoomOutGuard.test.mjs",
|
||||
"src/app/controls.test.mjs",
|
||||
"scripts/qa-zoom-out-guard.mjs",
|
||||
"src/app/atmosphereCompat.test.mjs",
|
||||
"src/globe/cesium/atmosphereCompat.test.mjs",
|
||||
"src/standalone/application.js",
|
||||
"src/standalone/controls.js",
|
||||
"src/standalone/data.js",
|
||||
@@ -369,8 +372,10 @@
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/aircraft/index.js",
|
||||
"src/layers/aircraft/classification.js",
|
||||
"src/layers/aircraft/tracking.js",
|
||||
"src/layers/military/ownership.test.mjs",
|
||||
"src/layers/aircraft/classification.test.mjs",
|
||||
"src/layers/aircraft/tracking.test.mjs",
|
||||
"src/data/aisLiveVessels.js",
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
@@ -476,17 +481,14 @@
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
"src/layers/installations/viewport.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/layers/installations/source.js",
|
||||
"src/layers/installations/state.js",
|
||||
"src/layers/installations/policy.js",
|
||||
"src/layers/installations/rendering.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/model.js",
|
||||
"src/layers/installations/selection.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/layers/awareness/lifecycle.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
@@ -522,20 +524,17 @@
|
||||
"src/layers/traffic/model.js",
|
||||
"src/layers/traffic/timing.js",
|
||||
"src/layers/traffic/style.js",
|
||||
"src/layers/bikeshare/lifecycle.js",
|
||||
"src/layers/bikeshare/viewport.js",
|
||||
"src/layers/bikeshare/registry.js",
|
||||
"src/layers/bikeshare/controls.js",
|
||||
"src/layers/bikeshare/source.js",
|
||||
"src/layers/bikeshare/state.js",
|
||||
"src/layers/bikeshare/policy.js",
|
||||
"src/layers/bikeshare/rendering.js",
|
||||
"src/layers/bikeshare/testing.js",
|
||||
"src/layers/bikeshare/ingestion.js",
|
||||
"src/layers/bikeshare/queries.js",
|
||||
"src/layers/bikeshare/index.js",
|
||||
"src/layers/bikeshare/model.js",
|
||||
"src/layers/bikeshare/selection.js",
|
||||
"src/data/traffic.js",
|
||||
"src/data/bikeshare.js",
|
||||
"src/layers/traffic/source.test.mjs",
|
||||
@@ -596,9 +595,9 @@
|
||||
"src/layers/alpr/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/overlay.test.mjs",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/source.test.mjs",
|
||||
@@ -629,13 +628,8 @@
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/layers/submarineCables/geometry.js",
|
||||
"src/layers/submarineCables/index.js",
|
||||
"src/layers/submarineCables/ingestion.js",
|
||||
"src/layers/submarineCables/interaction.js",
|
||||
"src/layers/submarineCables/lifecycle.js",
|
||||
"src/layers/submarineCables/overlay.js",
|
||||
"src/layers/submarineCables/policy.js",
|
||||
"src/layers/submarineCables/rendering.js",
|
||||
"src/layers/submarineCables/state.js",
|
||||
"src/layers/submarineCables/surface.js",
|
||||
"src/data/telegeographySubmarineCables.js",
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
@@ -651,9 +645,9 @@
|
||||
"src/maps/credits.js",
|
||||
"src/maps/defaultSources.js",
|
||||
"src/maps/google3d.js",
|
||||
"src/maps/imagery.js",
|
||||
"src/globe/cesium/mapImagery.js",
|
||||
"src/maps/registry.js",
|
||||
"src/maps/terrain.js",
|
||||
"src/globe/cesium/terrain.js",
|
||||
"src/maps/terrainRetry.js",
|
||||
"src/maps/terrainRetry.test.mjs",
|
||||
"src/mapStackController.js",
|
||||
@@ -817,8 +811,6 @@
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
"src/data/contactTrailRenderer.js",
|
||||
"src/data/contactTrailRenderer.test.mjs",
|
||||
"src/layers/transit/trails.js",
|
||||
"src/data/contactPlayback.test.mjs",
|
||||
"src/layers/transit/movement.js",
|
||||
@@ -845,7 +837,6 @@
|
||||
"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",
|
||||
@@ -854,7 +845,6 @@
|
||||
"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",
|
||||
@@ -880,8 +870,6 @@
|
||||
"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",
|
||||
"server/providers/droneAirspace.js",
|
||||
@@ -914,7 +902,6 @@
|
||||
"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",
|
||||
@@ -923,7 +910,6 @@
|
||||
"scripts/qa-weather-journey.mjs",
|
||||
"scripts/qa-weather-swap.mjs",
|
||||
"scripts/qa-weather-teardown.mjs",
|
||||
"src/layers/weather/imageryHost.test.mjs",
|
||||
"src/layers/weather/display.test.mjs",
|
||||
"src/sdr/adsbDecoder.test.mjs",
|
||||
"src/sdr/controller.test.mjs",
|
||||
@@ -948,8 +934,6 @@
|
||||
"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",
|
||||
@@ -1068,6 +1052,7 @@
|
||||
"src/layers/traffic/roadModes.test.mjs",
|
||||
"scripts/qa-traffic-motion.mjs",
|
||||
"src/layers/traffic/motion.test.mjs",
|
||||
"src/layers/traffic/handoff.test.mjs",
|
||||
"scripts/qa-journey-recorder.mjs",
|
||||
"scripts/qa-alpr-journey.mjs",
|
||||
"scripts/qa-context-arrivals.mjs",
|
||||
@@ -1116,7 +1101,7 @@
|
||||
"scripts/build-bright-stars.mjs",
|
||||
"scripts/build-milky-way.mjs",
|
||||
"src/sky/stars.test.mjs",
|
||||
"src/sky/starrySky.test.mjs",
|
||||
"src/globe/cesium/starrySky.test.mjs",
|
||||
"src/tooling/skyAssets.test.mjs",
|
||||
"src/tooling/skyAssets.paintOut.test.mjs",
|
||||
"scripts/qa-anime-sky.mjs",
|
||||
@@ -1216,6 +1201,198 @@
|
||||
"src/data/headingDegrees.test.mjs",
|
||||
"src/data/distanceFormat.test.mjs",
|
||||
"src/sources/adsbFields.test.mjs",
|
||||
"src/layers/flights/render/display.js",
|
||||
"src/layers/aircraft/render/display.test.mjs",
|
||||
"src/layers/aircraft/render/display.js",
|
||||
"src/layers/flights/renderer.test.mjs",
|
||||
"src/layers/military/renderer.test.mjs",
|
||||
"src/layers/military/policy.test.mjs",
|
||||
"src/testSupport/aircraftRecordingRenderer.mjs",
|
||||
"src/testSupport/cesiumMoversEngine.mjs",
|
||||
"src/testSupport/cockpitEngine.mjs",
|
||||
"src/layers/aircraft/render/contract.js",
|
||||
"src/layers/shared/color.test.mjs",
|
||||
"src/layers/shared/selectionKeeper.js",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/military/render/display.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/app/navaraLayers.test.mjs",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/vessels/render/contract.js",
|
||||
"src/layers/vessels/renderer.test.mjs",
|
||||
"src/layers/vessels/testFixtures.mjs",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||
"src/layers/cctv/lifecycle.test.mjs",
|
||||
"src/layers/cctv/projectionMedia.test.mjs",
|
||||
"src/layers/cctv/ingestion.test.mjs",
|
||||
"scripts/fixtures/layers/alpr.mjs",
|
||||
"scripts/fixtures/layers/cctv.mjs",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/radio/screenClusters.test.mjs",
|
||||
"src/globe/cesium/mapScene.js",
|
||||
"src/globe/cesium/mapScene.test.mjs",
|
||||
"src/globe/contract.test.mjs",
|
||||
"src/globe/testing/fakeEngine.test.mjs",
|
||||
"src/globe/cesium/events.test.mjs",
|
||||
"src/globe/cesium/camera.test.mjs",
|
||||
"src/globe/cesium/projector.test.mjs",
|
||||
"src/globe/cesium/picker.test.mjs",
|
||||
"src/globe/cesium/frames.test.mjs",
|
||||
"src/globe/cesium/render.test.mjs",
|
||||
"src/globe/cesium/surface.test.mjs",
|
||||
"src/globe/cesium/input.test.mjs",
|
||||
"src/globe/cesium/credits.test.mjs",
|
||||
"src/globe/cesium/maps.test.mjs",
|
||||
"src/globe/cesium/createCesiumEngine.test.mjs",
|
||||
"src/globe/cesium/movers.test.mjs",
|
||||
"src/globe/cesium/lines.test.mjs",
|
||||
"src/globe/cesium/testing/fakeViewer.mjs",
|
||||
"src/globe/cesium/testing/cameraViewer.mjs",
|
||||
"src/app/globeEngine.js",
|
||||
"src/app/globeEngine.test.mjs",
|
||||
"scripts/fixtures/layers/transit.mjs",
|
||||
"src/layers/localAdsb/render/display.js",
|
||||
"src/layers/localAdsb/renderer.test.mjs",
|
||||
"build/navaraAssets.js",
|
||||
"scripts/qa-engine-switch.mjs",
|
||||
"scripts/qa/tileCache.mjs",
|
||||
"src/annotations/resolverEngine.test.mjs",
|
||||
"src/voice/gevActionsEngine.test.mjs",
|
||||
"src/app/engineChoice.js",
|
||||
"src/app/engineChoice.test.mjs",
|
||||
"src/app/navaraMode.js",
|
||||
"src/app/navaraScene.test.mjs",
|
||||
"src/app/viewerlessTools.test.mjs",
|
||||
"src/cameraAngles.js",
|
||||
"src/cameraAngles.test.mjs",
|
||||
"src/data/cameraArrival.test.mjs",
|
||||
"src/data/cameraSensitivity.test.mjs",
|
||||
"src/globe/cesium/camera.js",
|
||||
"src/globe/cesium/convert.js",
|
||||
"src/globe/cesium/createCesiumEngine.js",
|
||||
"src/globe/cesium/credits.js",
|
||||
"src/globe/cesium/events.js",
|
||||
"src/globe/cesium/frames.js",
|
||||
"src/globe/cesium/input.js",
|
||||
"src/globe/cesium/maps.js",
|
||||
"src/globe/cesium/picker.js",
|
||||
"src/globe/cesium/projector.js",
|
||||
"src/globe/cesium/render.js",
|
||||
"src/globe/cesium/surface.js",
|
||||
"src/globe/cesium/movers.js",
|
||||
"src/globe/cesium/lines.js",
|
||||
"src/globe/contract.js",
|
||||
"src/globe/cameraMath.js",
|
||||
"src/globe/cameraMath.test.mjs",
|
||||
"src/globe/navara/cameraModel.js",
|
||||
"src/globe/navara/cameraModel.test.mjs",
|
||||
"src/globe/navara/createNavaraEngine.js",
|
||||
"src/globe/navara/createNavaraEngine.test.mjs",
|
||||
"src/globe/navara/credits.js",
|
||||
"src/globe/navara/credits.test.mjs",
|
||||
"src/globe/navara/groundFloor.js",
|
||||
"src/globe/navara/groundFloor.test.mjs",
|
||||
"src/globe/navara/index.js",
|
||||
"src/globe/navara/input.js",
|
||||
"src/globe/navara/input.test.mjs",
|
||||
"src/globe/navara/internals.js",
|
||||
"src/globe/navara/internals.test.mjs",
|
||||
"src/globe/navara/maps.js",
|
||||
"src/globe/navara/maps.test.mjs",
|
||||
"src/globe/navara/sharpen.js",
|
||||
"src/globe/navara/surface.js",
|
||||
"src/globe/navara/surface.test.mjs",
|
||||
"src/globe/navara/surfaceRay.test.mjs",
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/globe/navara/viewOptions.test.mjs",
|
||||
"src/globe/testing/contractSuite.mjs",
|
||||
"src/globe/testing/fakeEngine.js",
|
||||
"src/mapStackController.test.mjs",
|
||||
"src/orbit.test.mjs",
|
||||
"src/services/groundSnap.test.mjs",
|
||||
"src/sharelinkPose.test.mjs",
|
||||
"src/tooling/navaraAssets.test.mjs",
|
||||
"src/tooling/tileCache.test.mjs",
|
||||
"scripts/check-main-chunk.mjs",
|
||||
"src/tooling/mainChunkComposition.test.mjs",
|
||||
"src/tooling/initialChunkGuard.test.mjs",
|
||||
"src/globe/movers.test.mjs",
|
||||
"src/globe/navara/movers/pack.test.mjs",
|
||||
"src/globe/navara/movers/pickIds.test.mjs",
|
||||
"src/globe/navara/movers/moverMesh.test.mjs",
|
||||
"src/globe/navara/movers/modelMover.test.mjs",
|
||||
"src/bench/syntheticFlights.test.mjs",
|
||||
"src/globe/lines.test.mjs",
|
||||
"src/globe/navara/lines/lineMesh.test.mjs",
|
||||
"src/globe/pickDelivery.test.mjs",
|
||||
"src/globe/navara/picking.test.mjs",
|
||||
"src/data/selectionDispatcher.test.mjs",
|
||||
"src/globe/navara/cascadedLightCopy.test.mjs",
|
||||
"src/tooling/navaraDependencies.test.mjs",
|
||||
"build/navaraDependencies.js",
|
||||
"build/initialChunkGuard.js",
|
||||
"src/globe/follow.js",
|
||||
"src/globe/follow.test.mjs",
|
||||
"src/globe/cesium/follow.js",
|
||||
"src/globe/cesium/follow.test.mjs",
|
||||
"src/globe/surfaceDetail.js",
|
||||
"src/globe/surfaceDetail.test.mjs",
|
||||
"src/globe/navara/followGestures.js",
|
||||
"src/globe/navara/followGestures.test.mjs",
|
||||
"src/globe/cpuPicks.test.mjs",
|
||||
"src/app/layers/directory.js",
|
||||
"src/app/layers/renderers.js",
|
||||
"src/app/layers/renderers.test.mjs",
|
||||
"src/data/lifecycleEngine.test.mjs",
|
||||
"src/ui/shareRestorationEngine.test.mjs",
|
||||
"src/app/qaProbe.js",
|
||||
"src/app/qaProbe.test.mjs",
|
||||
"src/app/layers/qaSources.js",
|
||||
"src/app/layers/qaSources.test.mjs",
|
||||
"src/bench/w1Feeds.js",
|
||||
"src/bench/satelliteFixture.js",
|
||||
"src/bench/satelliteFixture.test.mjs",
|
||||
"src/globe/cesium/celestial.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/geo/ellipsoidGeodesic.test.mjs",
|
||||
"src/bench/launchFixture.js",
|
||||
"src/layers/launches/replayClock.test.mjs",
|
||||
"src/geo/boundingSphere.test.mjs",
|
||||
"src/layers/launches/launchPadFrame.test.mjs",
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"src/globe/cesium/sceneProjection.js",
|
||||
"src/layers/flights/trackingRefresh.test.mjs",
|
||||
"scripts/qa-heading-navara.mjs",
|
||||
"src/layers/aircraft/qaDrawn.test.mjs",
|
||||
"src/globe/cesium/screenEvents.js",
|
||||
"src/globe/navara/movers/frameCamera.js",
|
||||
"src/globe/navara/overlayPass.js",
|
||||
"src/globe/navara/overlayPass.test.mjs",
|
||||
"src/globe/navara/movers/modelTint.js",
|
||||
"src/globe/navara/movers/frameCamera.test.mjs",
|
||||
"src/layers/localAdsb/qaDrawn.js",
|
||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||
"src/tooling/browserRuntimeEvidence.test.mjs",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/rectangle.test.mjs",
|
||||
"src/layers/firms/viewport.test.mjs",
|
||||
"scripts/fixtures/layers/firms.mjs",
|
||||
"src/layers/firms/lifecycle.test.mjs",
|
||||
"scripts/fixtures/layers/radio.mjs",
|
||||
"scripts/qa-map-policy.mjs",
|
||||
"scripts/qa-map-policy.test.mjs",
|
||||
"scripts/qa-imagery-contract.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
"src/globe/navara/emptySceneRender.test.mjs",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/firmsIntensity.test.mjs",
|
||||
"src/ui/layerPanel/RowSwitch.vue",
|
||||
@@ -1356,5 +1533,169 @@
|
||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||
"src/layers/wildlifeSightings/density.test.mjs",
|
||||
"src/layers/wildlifeSightings/selection.test.mjs",
|
||||
"server/providers/inaturalist/proxy.fixture.mjs"
|
||||
"server/providers/inaturalist/proxy.fixture.mjs",
|
||||
"scripts/report-engine-coupling.mjs",
|
||||
"scripts/engineCoupling.test.mjs",
|
||||
"scripts/qa-contract-suite.mjs",
|
||||
"src/globe/cesium/doubleClick.js",
|
||||
"scripts/engine-coupling-baseline.json",
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/layers/alpr/render/display.js",
|
||||
"src/layers/alpr/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/display.js",
|
||||
"src/layers/bikeshare/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/style.js",
|
||||
"src/layers/launches/render/display.js",
|
||||
"src/layers/launches/render/display.test.mjs",
|
||||
"src/layers/osmPlaces/render/display.js",
|
||||
"src/layers/osmPlaces/render/display.test.mjs",
|
||||
"src/layers/satellites/render/display.js",
|
||||
"src/layers/satellites/render/display.test.mjs",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/vessels/render/display.test.mjs",
|
||||
"scripts/qa-wildlife-switch.mjs",
|
||||
"scripts/fixtures/wildlife.mjs",
|
||||
"src/layers/cctv/render/display.js",
|
||||
"src/layers/cctv/render/display.test.mjs",
|
||||
"src/layers/cctv/render/displayGizmo.test.mjs",
|
||||
"src/layers/cctv/render/meshes.js",
|
||||
"src/layers/cctv/render/meshes.test.mjs",
|
||||
"src/layers/cctv/render/style.js",
|
||||
"src/layers/radio/render/display.js",
|
||||
"src/layers/radio/render/display.test.mjs",
|
||||
"src/layers/radio/render/style.js",
|
||||
"src/layers/transit/render/display.js",
|
||||
"src/layers/transit/render/display.test.mjs",
|
||||
"src/layers/transit/render/style.js",
|
||||
"src/layers/transit/render/testAtlas.mjs",
|
||||
"src/testSupport/cctvEngines.mjs",
|
||||
"src/testSupport/radioTestEngine.mjs",
|
||||
"src/globe/ground.js",
|
||||
"src/globe/ground.test.mjs",
|
||||
"src/globe/cesium/ground.js",
|
||||
"src/globe/cesium/ground.test.mjs",
|
||||
"src/globe/navara/ground/index.js",
|
||||
"src/globe/navara/ground/index.test.mjs",
|
||||
"src/globe/navara/ground/pack.js",
|
||||
"src/globe/navara/ground/pack.test.mjs",
|
||||
"src/globe/navara/ground/pass.js",
|
||||
"docs/GLOBE-PRIMITIVES.md",
|
||||
"src/layers/airQuality/render/display.js",
|
||||
"src/layers/airQuality/render/display.test.mjs",
|
||||
"src/layers/cyclones/render/display.js",
|
||||
"src/layers/cyclones/render/display.test.mjs",
|
||||
"src/layers/droneAirspace/render/display.js",
|
||||
"src/layers/droneAirspace/render/display.test.mjs",
|
||||
"src/layers/earthquakes/render/display.js",
|
||||
"src/layers/earthquakes/render/display.test.mjs",
|
||||
"src/layers/firms/render/display.js",
|
||||
"src/layers/firms/render/display.test.mjs",
|
||||
"src/layers/firms/render/sprites.js",
|
||||
"src/layers/ghostMaps/render/display.js",
|
||||
"src/layers/ghostMaps/render/display.test.mjs",
|
||||
"src/layers/gnss/render/display.js",
|
||||
"src/layers/gnss/render/display.test.mjs",
|
||||
"src/layers/perimeters/render/display.js",
|
||||
"src/layers/perimeters/render/display.test.mjs",
|
||||
"src/layers/submarineCables/render/display.js",
|
||||
"src/layers/submarineCables/render/display.test.mjs",
|
||||
"src/testSupport/groundDisplayEngines.mjs",
|
||||
"src/globe/cesium/groundPulse.js",
|
||||
"src/layers/awareness/render/display.js",
|
||||
"src/layers/awareness/render/display.test.mjs",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/infrastructure/render/display.test.mjs",
|
||||
"src/layers/installations/render/display.js",
|
||||
"src/layers/installations/render/display.test.mjs",
|
||||
"src/layers/installations/render/displayHarness.mjs",
|
||||
"src/layers/installations/render/namedMarkers.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/installations/selection.test.mjs",
|
||||
"src/layers/installations/selectionFill.test.mjs",
|
||||
"src/layers/installations/lifecycle.test.mjs",
|
||||
"src/layers/installations/navigation.test.mjs",
|
||||
"src/layers/infrastructure/index.js",
|
||||
"src/layers/infrastructure/session.test.mjs",
|
||||
"src/data/localGeojson.test.mjs",
|
||||
"src/layers/directions/render/display.js",
|
||||
"src/layers/directions/render/display.test.mjs",
|
||||
"src/layers/directions/render/style.js",
|
||||
"src/layers/streetLevel/render/display.js",
|
||||
"src/layers/streetLevel/render/display.test.mjs",
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/traffic/render/display.test.mjs",
|
||||
"src/layers/directions/edgeCases.test.mjs",
|
||||
"src/layers/streetLevel/edgeCases.test.mjs",
|
||||
"src/globe/cesium/imagery.js",
|
||||
"src/globe/cesium/imagery.test.mjs",
|
||||
"src/globe/cesium/imageryShell.test.mjs",
|
||||
"src/globe/imagery.js",
|
||||
"src/globe/imagery.test.mjs",
|
||||
"src/globe/navara/imagery/index.js",
|
||||
"src/globe/navara/imagery/index.test.mjs",
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/layers/recentImagery/render/display.test.mjs",
|
||||
"src/layers/weather/render/display.test.mjs",
|
||||
"src/layers/wind/render/display.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/display.test.mjs",
|
||||
"src/globe/navara/navaraGroundOrder.test.mjs",
|
||||
"src/globe/navara/ground/corridors.js",
|
||||
"src/globe/cesium/windFlow.js",
|
||||
"src/testSupport/imageryDisplayEngines.mjs",
|
||||
"src/data/geoImportDisplay.js",
|
||||
"src/data/geoImportDisplay.test.mjs",
|
||||
"src/data/geoImportKml.js",
|
||||
"src/data/geoImportKml.test.mjs",
|
||||
"src/data/geoImportRecords.js",
|
||||
"src/globe/cesium/atmosphereCompat.js",
|
||||
"src/globe/cesium/postStages.js",
|
||||
"src/globe/cesium/postStages.test.mjs",
|
||||
"src/globe/cesium/sonar.js",
|
||||
"src/globe/cesium/sonar.test.mjs",
|
||||
"src/globe/cesium/sonarCommands.js",
|
||||
"src/globe/cesium/sonarCommands.test.mjs",
|
||||
"src/globe/cesium/starrySky.js",
|
||||
"src/globe/cesium/zoomOutGuard.js",
|
||||
"scripts/qa-geo-import-probe.mjs",
|
||||
"src/globe/cesium/meshes.js",
|
||||
"src/globe/meshes.js",
|
||||
"src/globe/navara/meshes.js",
|
||||
"src/globe/navara/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.js",
|
||||
"src/scenes/dataPacks/shapes.js",
|
||||
"src/scenes/dataPacks/shapes.test.mjs",
|
||||
"src/globe/cesium/meshes.test.mjs",
|
||||
"src/globe/meshes.test.mjs",
|
||||
"src/scenes/dataPacks/display.test.mjs",
|
||||
"src/globe/exclusivePointer.js",
|
||||
"src/globe/cesium/followOffset.js",
|
||||
"src/annotations/drawSurface.js",
|
||||
"src/annotations/drawSurface.test.mjs",
|
||||
"src/annotations/drawToolBehaviour.test.mjs",
|
||||
"src/globe/navara/ground/pulse.js",
|
||||
"src/globe/navara/ground/surface.js",
|
||||
"src/layers/bhoteKoshi/render/story.js",
|
||||
"src/globe/groundPulse.js",
|
||||
"src/globe/groundPulse.test.mjs",
|
||||
"src/globe/navara/ground/pulse.test.mjs",
|
||||
"src/layers/bhoteKoshi/render/story.test.mjs",
|
||||
"src/globe/navara/windFlowMesh.js",
|
||||
"src/data/layerSceneBinding.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/standalone/sceneSwitcher.js",
|
||||
"src/app/layerSession.test.mjs",
|
||||
"src/standalone/sceneSwitcher.test.mjs",
|
||||
"src/app/layers/displayRehearsal.test.mjs",
|
||||
"src/globe/cesium/annotations/worldAnnotationRenderer.js",
|
||||
"src/globe/cesium/annotations/screenAnnotationRenderer.js",
|
||||
"src/globe/navara/windFlow.js",
|
||||
"src/overlays/bindOverlayHost.js",
|
||||
"src/data/trackedReadoutScenes.test.mjs",
|
||||
"src/data/trackedAircraftScenes.test.mjs",
|
||||
"src/ui/releaseTrackers.js",
|
||||
"src/testSupport/domClasses.mjs",
|
||||
"src/layers/shared/imageryHost.js",
|
||||
"src/layers/shared/imageryHost.test.mjs"
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"gzipBytes": 2066560,
|
||||
"allowanceBytes": 20666,
|
||||
"measuredAt": "Keyless production build with dynamically loaded layer displays",
|
||||
"allowance": "1% of the baseline; growth past it fails npm run check:main-chunk until the baseline is raised on purpose"
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
import { vueScripts } from './vue-scripts.mjs';
|
||||
|
||||
const browserGlobals = new Set([
|
||||
'window',
|
||||
@@ -15,9 +16,14 @@ const browserGlobals = new Set([
|
||||
'getComputedStyle',
|
||||
]);
|
||||
|
||||
/** Inspect literal module dependencies and browser-platform references without executing code. */
|
||||
export function analyzeModule(source) {
|
||||
/**
|
||||
* Inspect literal module dependencies and browser-platform references without
|
||||
* executing code. With `dynamic`, also list the import() specifiers apart.
|
||||
*/
|
||||
export function analyzeModule(source, { dynamic = false, filename = '' } = {}) {
|
||||
const imports = [];
|
||||
const dynamicImports = [];
|
||||
const staticImports = [];
|
||||
const browser = new Set();
|
||||
function walk(node, parent, field) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
@@ -35,6 +41,9 @@ export function analyzeModule(source) {
|
||||
'Computed module imports are not allowed in runtime code',
|
||||
);
|
||||
imports.push(node.source.value);
|
||||
if (node.type === 'ImportExpression')
|
||||
dynamicImports.push(node.source.value);
|
||||
else staticImports.push(node.source.value);
|
||||
}
|
||||
if (node.type === 'CallExpression' && node.callee?.name === 'require')
|
||||
throw new Error('Runtime modules must use ES imports');
|
||||
@@ -70,8 +79,15 @@ export function analyzeModule(source) {
|
||||
else if (value && typeof value === 'object') walk(value, node, key);
|
||||
}
|
||||
}
|
||||
walk(parsers.babel.parse(source));
|
||||
return { imports, browser: [...browser] };
|
||||
for (const script of filename.endsWith('.vue')
|
||||
? vueScripts(source, filename)
|
||||
: [source])
|
||||
walk(parsers.babel.parse(script));
|
||||
return {
|
||||
imports,
|
||||
browser: [...browser],
|
||||
...(dynamic && { dynamicImports, staticImports }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Return static imports, literal dynamic imports and re-exports. */
|
||||
|
||||
+706
-152
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-heading-b3.mjs — throwaway QA harness for skylight Task 4
|
||||
* qa-aircraft-heading.mjs — aircraft display-heading checks
|
||||
* (path-derived, rate-limited display course; Batch 3 overnight run).
|
||||
*
|
||||
* The permanent track-regression harness flies planes STRAIGHT, so it cannot
|
||||
@@ -19,8 +19,9 @@
|
||||
* add order and the app registered first), i.e. at the exact wall-clock the
|
||||
* rate limiter advanced — postRender sampling skews apparent rates by the
|
||||
* variable render-pass duration (~40% at SwiftShader's ~3 fps). The course
|
||||
* is derived from the tracked standalone Cesium.Model's modelMatrix (the
|
||||
* actually-rendered transform), inverting _modelMatrix: with pitch=roll=0
|
||||
* is derived from the tracked model's heading-pitch-roll frame (the layer's
|
||||
* model handle, read through the QA probe's layers.drawn on both engines;
|
||||
* the drawn Cesium Model adds the glTF axis turn), inverting it: with pitch=roll=0
|
||||
* the matrix is ENU·Rz(−h), so local x in ENU = (cos h, −sin h, 0) →
|
||||
* h = atan2(−x·north, x·east), course = norm360(deg(h) − MODEL_HEADING_OFFSET).
|
||||
* The 15 s fix cadence × 3°/s turn = 45° course step per interpolation
|
||||
@@ -44,7 +45,7 @@
|
||||
* normalized nose -X, offset 180°).
|
||||
*
|
||||
* VISUAL — with the turning flights plane tracked in 3D, capture screenshots
|
||||
* (two zoom levels × two orbit angles) into qa-shots/b3/ for human review:
|
||||
* (two zoom levels × two orbit angles) into qa-shots/aircraft-heading/ for human review:
|
||||
* the nose must lead the curved trail, never sideways/backwards.
|
||||
*
|
||||
* LOW-SPEED (heading-v2, 2026-07-03 field-test regression) — two extra
|
||||
@@ -92,13 +93,14 @@
|
||||
* exceeds 15°/s (truth is 2°/s; the pre-fix snap replayed ~120° of frozen
|
||||
* divergence at the 60°/s slew cap).
|
||||
*
|
||||
* Run: node scripts/qa-heading-b3.mjs --url http://localhost:4300
|
||||
* Run: node scripts/qa-aircraft-heading.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installNavaraHeadingProbe } from './qa-heading-navara.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as track-regression.mjs)
|
||||
@@ -110,9 +112,15 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b3');
|
||||
const SHOT_DIR = path.resolve('qa-shots/aircraft-heading');
|
||||
const SHOT_DIR_V2 = path.resolve('qa-shots/heading-v2');
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -148,7 +156,7 @@ function record(name, ok, detail) {
|
||||
|
||||
async function observeTrackedHostPaint(page, timeoutMs = 5000) {
|
||||
return page.evaluate((boundedTimeoutMs) => new Promise((resolve) => {
|
||||
const viewer = window.__godsEyeView?.viewer;
|
||||
const viewer = (window.__headingView ?? window.__godsEyeView?.viewer);
|
||||
const scene = viewer?.scene;
|
||||
const history = [];
|
||||
const startedAt = performance.now();
|
||||
@@ -453,7 +461,7 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -480,6 +488,7 @@ async function main() {
|
||||
|
||||
// ---- Turning-plane fetch shim (installed before any app code runs) ------
|
||||
await page.evaluateOnNewDocument((turn) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__TURN = turn;
|
||||
window.__TURN.epochMs = Date.now(); // arc phase zero = shim install time
|
||||
window.__TURN_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -574,58 +583,42 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- Model finder + course derivation, installed once ------------------
|
||||
await page.evaluate(() => {
|
||||
// Every sampler in this harness targets a KNOWN aircraft, and both the
|
||||
// fleet models and the tracked standalone model carry id=icao — so the
|
||||
// finder selects by id when given one. (The old nearest-to-tracked
|
||||
// fallback could mis-pick ANOTHER plane's model for a frame whenever
|
||||
// the tracked display position read null, which injected a one-frame
|
||||
// ~90° course spike into the sampled series — a measurement flake, not
|
||||
// a product regression.)
|
||||
// fleet models and the tracked model carry id=icao — so the finder
|
||||
// selects by id. The models draw through the globe contract (engine
|
||||
// model movers), on Cesium as on Navara; the layer's drawn rows
|
||||
// (window.__godsEyeView.layers.drawn, src/layers/aircraft/qaDrawn.js)
|
||||
// give each model's heading-pitch-roll frame, the matrix the course
|
||||
// derivation below inverts. The Cesium Model a mover set draws carries
|
||||
// that frame times the layer's glTF axis turn (GLTF_AXIS in
|
||||
// src/layers/aircraft/render/display.js), so its own modelMatrix reads
|
||||
// 90° off; the handle's ready/show say whether the engine draws it.
|
||||
window.__findTrackedModel = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = coll.get(i);
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined') {
|
||||
if (icao && p.id !== icao) continue;
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const shown = out.filter((m) => m.show && m.ready);
|
||||
if (icao) return shown[0] || null; // deterministic: the plane's own model or nothing
|
||||
const pool = shown.length ? shown : out;
|
||||
if (!pool.length) return null;
|
||||
const ent = v.trackedEntity;
|
||||
let tracked = null;
|
||||
if (ent && typeof ent.gevDisplayPosition === 'function') tracked = ent.gevDisplayPosition();
|
||||
if (!tracked) return pool[0];
|
||||
let best = null, bestD = Infinity;
|
||||
for (const m of pool) {
|
||||
const mm = m.modelMatrix;
|
||||
const dx = mm[12] - tracked.x, dy = mm[13] - tracked.y, dz = mm[14] - tracked.z;
|
||||
const d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < bestD) { bestD = d; best = m; }
|
||||
}
|
||||
return best;
|
||||
const rows = ['flights', 'military']
|
||||
.flatMap((layer) => window.__godsEyeView.layers.drawn(layer) ?? [])
|
||||
.filter((row) => row.id === icao);
|
||||
const row =
|
||||
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||
rows.find((r) => r.model?.ready && r.model.show);
|
||||
return row
|
||||
? { id: icao, ready: row.model.ready, show: row.model.show, modelMatrix: row.model.matrix }
|
||||
: null;
|
||||
};
|
||||
|
||||
// Invert _modelMatrix (pitch=roll=0): local x in ENU = (cos h, −sin h, 0)
|
||||
// → h = atan2(−x·north, x·east); world course = h − headingOffsetDeg.
|
||||
window.__courseFromModelMatrix = function (mm, headingOffsetDeg) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const C3 = v.camera.position.constructor;
|
||||
const Carto = v.camera.positionCartographic.constructor;
|
||||
const carto = Carto.fromCartesian(new C3(mm[12], mm[13], mm[14]));
|
||||
@@ -646,7 +639,7 @@ async function main() {
|
||||
// preUpdate/preRender listeners and reads the rendered model matrix, but
|
||||
// does not depend on ambient render-loop scheduling for sample count.
|
||||
window.__sampleModelCourse = async function (icao, headingOffsetDeg, windowMs, stepMs = 100) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -685,13 +678,15 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
await installNavaraHeadingProbe(page);
|
||||
|
||||
// ---- Enable layers with fixes back-dated 32 s, then prime forward ------
|
||||
// First update() fires inside setEnabled, so the FIRST fix must already be
|
||||
// the oldest (history appends only monotonically-newer fix times).
|
||||
console.log('Priming turning history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
window.__TURN.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
@@ -726,7 +721,10 @@ async function main() {
|
||||
|
||||
const flModelUp = await page.waitForFunction((icao) => {
|
||||
const m = window.__findTrackedModel(icao);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === icao && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.flights[0].icao).then(() => true).catch(() => false);
|
||||
record('flights: tracked 3D model rendered (ready+shown)', flModelUp, flModelUp ? 'model up' : 'model never became ready');
|
||||
if (!flModelUp) { finish(); return; }
|
||||
@@ -776,21 +774,21 @@ async function main() {
|
||||
}
|
||||
|
||||
// ---- Screenshots: two zoom levels × two orbit angles --------------------
|
||||
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/b3/');
|
||||
console.log('\n Screenshots (turning plane tracked in 3D) → qa-shots/aircraft-heading/');
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager._setDetectionMode('DENSE');
|
||||
});
|
||||
await sleep(800);
|
||||
const shots = [
|
||||
['flights-far-orbitA.png', async () => { /* default follow framing */ }],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.9); })],
|
||||
['flights-far-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.9); })],
|
||||
['flights-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// Synthetic cruise frame settles ~5.6 km out; leave ~430 m so model,
|
||||
// readout anchor, tracking line, and trail can be judged close-up.
|
||||
cam.rotateRight(-1.9); cam.zoomIn(5200);
|
||||
})],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateLeft(1.2); })],
|
||||
['flights-near-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateLeft(1.2); })],
|
||||
];
|
||||
for (const [name, move] of shots) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -812,7 +810,10 @@ async function main() {
|
||||
|
||||
const milModelUp = await page.waitForFunction((hex) => {
|
||||
const m = window.__findTrackedModel(hex);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('military').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === hex && following);
|
||||
}, { timeout: 40000, polling: 250 }, TURN.military[0].hex).then(() => true).catch(() => false);
|
||||
record('military: tracked 3D model rendered (ready+shown)', milModelUp, milModelUp ? 'model up' : 'model never became ready');
|
||||
if (milModelUp) {
|
||||
@@ -841,14 +842,14 @@ async function main() {
|
||||
await sleep(800);
|
||||
for (const [name, move] of [
|
||||
['military-orbitA.png', async () => { }],
|
||||
['military-orbitB.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.rotateRight(1.6); })],
|
||||
['military-orbitB.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.6); })],
|
||||
['military-near-orbitA.png', () => page.evaluate(() => {
|
||||
const cam = window.__godsEyeView.viewer.camera;
|
||||
const cam = (window.__headingView ?? window.__godsEyeView.viewer).camera;
|
||||
// 12,000 ft synthetic frame settles ~6.3 km out; leave ~630 m.
|
||||
cam.rotateLeft(1.6); cam.zoomIn(5700);
|
||||
})],
|
||||
['military-near-orbitB.png', () => page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.camera.rotateRight(1.2);
|
||||
(window.__headingView ?? window.__godsEyeView.viewer).camera.rotateRight(1.2);
|
||||
})],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
@@ -877,7 +878,10 @@ async function main() {
|
||||
}, icao);
|
||||
const up = await page.waitForFunction((id) => {
|
||||
const m = window.__findTrackedModel(id);
|
||||
return !!(m && m.ready && m.show);
|
||||
const app = window.__godsEyeView;
|
||||
const selected = app.dataManager.layers.get('flights').module.getTrackedInfo();
|
||||
const following = app.engine.kind === 'navara' ? app.engine.camera.following() : app.viewer.trackedEntity;
|
||||
return !!(m && m.ready && m.show && selected?.icao24 === id && following);
|
||||
}, { timeout: 40000, polling: 250 }, icao).then(() => true).catch(() => false);
|
||||
record(`${label}: tracked 3D model rendered (ready+shown)`, up, up ? 'model up' : 'model never became ready');
|
||||
return up;
|
||||
@@ -914,7 +918,7 @@ async function main() {
|
||||
});
|
||||
for (const [name, move] of [
|
||||
['slowturn-far.png', async () => { /* default follow framing */ }],
|
||||
['slowturn-near.png', () => page.evaluate(() => { window.__godsEyeView.viewer.camera.zoomIn(2500); })],
|
||||
['slowturn-near.png', () => page.evaluate(() => { (window.__headingView ?? window.__godsEyeView.viewer).camera.zoomIn(2500); })],
|
||||
]) {
|
||||
try { await move(); } catch (e) { console.log(` (camera move for ${name} failed: ${e.message})`); }
|
||||
await sleep(1200);
|
||||
@@ -930,22 +934,7 @@ async function main() {
|
||||
// ========================================================================
|
||||
console.log('\nPhase 5 — 65 kt helicopter: tracked↔fleet course handoff (click / click-away)');
|
||||
await page.evaluate(() => {
|
||||
window.__findModelByIcao = function (icao) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.id === icao && p.show && p.ready) out.push(p);
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out[0] || null;
|
||||
};
|
||||
window.__findModelByIcao = window.__findTrackedModel;
|
||||
});
|
||||
|
||||
// Activate the heli in the feed now (kept out of phases 1–4), ingest it,
|
||||
@@ -957,7 +946,7 @@ async function main() {
|
||||
window.__TURN.heli65.active = true;
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
fl.setParams({ models3d: false });
|
||||
await fl.update(window.__godsEyeView.viewer); // ingest the first heli fix
|
||||
await fl.update((window.__headingView ?? window.__godsEyeView.viewer)); // ingest the first heli fix
|
||||
});
|
||||
await sleep(4000); // several fleet ticks establish the heli's fleet course
|
||||
await page.evaluate((icao) => {
|
||||
@@ -966,7 +955,7 @@ async function main() {
|
||||
const heliTracked = await page.waitForFunction((icao) => {
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const ti = fl.getTrackedInfo();
|
||||
return !!(ti && ti.icao24 === icao && window.__godsEyeView.viewer.trackedEntity);
|
||||
return !!(ti && ti.icao24 === icao && (window.__headingView ?? window.__godsEyeView.viewer).trackedEntity);
|
||||
}, { timeout: 15000, polling: 250 }, TURN.heli65.icao).then(() => true).catch(() => false);
|
||||
record('heli65: tracking engaged (2D billboard mode)', heliTracked,
|
||||
heliTracked ? 'tracked' : 'tracking never engaged');
|
||||
@@ -983,9 +972,10 @@ async function main() {
|
||||
// rotation after. The release-in-place keeps the camera static, so
|
||||
// rotation deltas across the boundary are course deltas.
|
||||
const c1 = await page.evaluate(async ({ icao, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const findFleetBillboard = (id) => {
|
||||
if (window.__headingView) return window.__godsEyeView.layers.drawn('flights').find((row) => row.id === id && row.show);
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
@@ -1037,7 +1027,16 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.setParams({ models3d: true });
|
||||
});
|
||||
const fleetModelUp = await page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (window.__headingView) {
|
||||
const deadline = performance.now() + 40000;
|
||||
while (performance.now() < deadline) {
|
||||
if (window.__findModelByIcao(icao)) return true;
|
||||
window.__godsEyeView.engine.render.request('heading-probe');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const tileset = window.__godsEyeView.tileset;
|
||||
const priorDefaultLoop = v.useDefaultRenderLoop;
|
||||
const priorTilesetShow = tileset?.show;
|
||||
@@ -1061,7 +1060,7 @@ async function main() {
|
||||
record('heli65 C2 (click): fleet model rendered before re-track', false, 'fleet model never became ready');
|
||||
} else {
|
||||
const c2 = await page.evaluate(async ({ icao, offsetDeg, actionAtMs, windowMs }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = (window.__headingView ?? window.__godsEyeView.viewer);
|
||||
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const out = [];
|
||||
const start = performance.now();
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* qa-sprites-b5.mjs — throwaway QA harness for skylight Tasks 8+9
|
||||
* qa-aircraft-sprites.mjs — aircraft sprite checks
|
||||
* (type-aware billboard silhouettes + per-class 2D/3D scale; Batch 5).
|
||||
*
|
||||
* Injects synthetic STRAIGHT-FLYING aircraft covering ALL 8 classifyAircraft
|
||||
* classes — the flights layer via OpenSky extended categories (state[17]),
|
||||
* the military layer via adsb.lol type codes — using the same fetch-shim +
|
||||
* history-priming pattern as scripts/qa-heading-b3.mjs (positions analytic at
|
||||
* history-priming pattern as scripts/qa-aircraft-heading.mjs (positions analytic at
|
||||
* serve time so every fix is self-consistent through the render delay).
|
||||
*
|
||||
* Machine assertions:
|
||||
@@ -15,17 +15,17 @@
|
||||
* per-class glyph (image data-URI === aircraftIcon(klass),
|
||||
* imported from src) and per-class scale (flights:
|
||||
* CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D)
|
||||
* B3 models : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* Model scales : with 3D on, fleet Cesium.Model.scale === the layer's
|
||||
* MODEL_SCALE (both 1; legacy scale is baked into the GLB)
|
||||
* × CLASS_SCALE_3D[klass] per icao; tracked too
|
||||
* B4 clean : no console errors
|
||||
*
|
||||
* Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows,
|
||||
* Visual output (qa-shots/aircraft-sprites/, untracked): top-down 2D spreads of both rows,
|
||||
* per-row close-ups, a tracked-cyan tint shot, and 3D cluster shots at two
|
||||
* zooms per layer showing per-class model scale differences. The PNGs are the
|
||||
* acceptance test — read them.
|
||||
*
|
||||
* Run: node scripts/qa-sprites-b5.mjs --url http://localhost:4300
|
||||
* Run: node scripts/qa-aircraft-sprites.mjs --url http://localhost:4300
|
||||
* Exits non-zero if any assertion fails. Never commits anything.
|
||||
*/
|
||||
|
||||
@@ -44,7 +44,7 @@ const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-heading-b3.mjs)
|
||||
// Args (same shape as qa-aircraft-heading.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
@@ -53,9 +53,15 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const BASE_URL = getOpt('--url', 'http://localhost:4173');
|
||||
// --engine=cesium|navara: on both engines the layers are read through the QA
|
||||
// probe (window.__godsEyeView.layers.drawn, the dev server's); the military
|
||||
// checks are skipped where the engine has no military layer.
|
||||
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/b5');
|
||||
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/aircraft-sprites' : `qa-shots/aircraft-sprites-${ENGINE}`);
|
||||
|
||||
const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale)
|
||||
const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb
|
||||
@@ -131,7 +137,7 @@ const SPRITES = {
|
||||
{ hex: 'dda006', flight: 'ML-QUAD', t: 'B744', lon0: ROW_LON0 + 5 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 13000 },
|
||||
{ hex: 'dda007', flight: 'ML-HEL', t: 'H60', lon0: ROW_LON0 + 6 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 40, altFt: 3000 },
|
||||
// course 0 like the rest — keeps the row a clean size-comparison line (2D
|
||||
// rotation is demoed by caa004 and gated by qa-heading-b3 anyway).
|
||||
// rotation is demoed by caa004 and gated by qa-aircraft-heading anyway).
|
||||
{ hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 },
|
||||
// 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a
|
||||
// real-world-scale asset (native bounding radius ~29.8) rendered at
|
||||
@@ -173,7 +179,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -200,6 +206,7 @@ async function main() {
|
||||
|
||||
// ---- Straight-flight fetch shim (installed before any app code runs) ----
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__SPR = spec;
|
||||
window.__SPR.epochMs = Date.now();
|
||||
window.__SPR_HITS = { opensky: 0, mil: 0 };
|
||||
@@ -275,52 +282,33 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
// A cold dev server compiles the Navara chunks on first load.
|
||||
{ timeout: 180000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page probes: billboard walk, model walk, tracked-model finder ---
|
||||
await page.evaluate(() => {
|
||||
// Every billboard (image + alignedAxis + id) in every collection.
|
||||
// Every fleet glyph the flights and military layers draw, through the
|
||||
// engine-neutral probe (layers.drawn: the layers' own sprite handles),
|
||||
// on either engine. The layers no longer own Cesium billboards: on
|
||||
// Cesium the contract's batched billboards carry atlas keys, not the
|
||||
// glyph data-URIs.
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
};
|
||||
// Every glTF model primitive (modelMatrix + ready flag), with id + scale.
|
||||
// Every fleet and tracked 3D model, with id + scale (the probe's model
|
||||
// handles). The world bounding radius is not on the contract: null.
|
||||
window.__collectModels = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.modelMatrix && typeof p.ready !== 'undefined' && p.image === undefined) {
|
||||
let radius = null;
|
||||
try { radius = p.ready && p.boundingSphere ? p.boundingSphere.radius : null; } catch { /* not ready */ }
|
||||
out.push({ id: p.id, scale: p.scale, ready: p.ready, show: p.show, radius });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
return ['flights', 'military'].flatMap((id) => window.__godsEyeView.layers.drawn(id) ?? [])
|
||||
.filter((row) => row.model && (row.trackedModel || !row.tracked))
|
||||
.map(({ id, model }) => ({ id, ...model, radius: null }));
|
||||
};
|
||||
// Displayed (render-delayed) latitude of a synthetic plane right now.
|
||||
window.__displayedState = function (layer, idx, renderDelaySec) {
|
||||
@@ -331,43 +319,46 @@ async function main() {
|
||||
};
|
||||
// Canvas coordinates of every synthetic plane at its displayed position —
|
||||
// ground truth for identifying which glyph is which in a screenshot.
|
||||
window.__planeCanvasCoords = function (layer, renderDelaySec, altOf) {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
window.__planeCanvasCoords = async function (layer, renderDelaySec, altOf) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { engine } = window.__godsEyeView;
|
||||
const S = window.__SPR;
|
||||
const C3 = v.camera.position.constructor;
|
||||
return S[layer].map((p, i) => {
|
||||
const s = window.__displayedState(layer, i, renderDelaySec);
|
||||
const pos = C3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = v.scene.cartesianToCanvasCoordinates(pos);
|
||||
const pos = geo.cartesian3.fromDegrees(s.lon, s.lat, altOf === 'ft' ? p.altFt * 0.3048 : p.altM);
|
||||
const c = engine.projector.worldToWindow(pos);
|
||||
return `${p.icao || p.hex}=(${c ? Math.round(c.x) : '-'},${c ? Math.round(c.y) : '-'})`;
|
||||
}).join(' ');
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Prime history through the render delay (same pattern as b3) -------
|
||||
// ---- Prime history through the render delay (same pattern as the heading checks) -------
|
||||
console.log('Priming straight-flight history through the render delay (30 s / 15 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
const hasMil = dm.layers.has('military');
|
||||
window.__SPR.timeOffsetSec = -32;
|
||||
await dm.setEnabled('flights', true);
|
||||
await dm.setEnabled('military', true);
|
||||
if (hasMil) await dm.setEnabled('military', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
const mil = dm.layers.get('military').module;
|
||||
const mil = hasMil ? dm.layers.get('military').module : null;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
window.__SPR.timeOffsetSec = off;
|
||||
await fl.update(v);
|
||||
await mil.update(v);
|
||||
await mil?.update(v);
|
||||
}
|
||||
window.__SPR.timeOffsetSec = 0;
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil.getStats().count, hits: window.__SPR_HITS };
|
||||
window.__SPR_DRIVER = setInterval(() => { fl.update(v); mil?.update(v); }, 15000);
|
||||
return { fl: fl.getStats().count, mil: mil ? mil.getStats().count : null, hits: window.__SPR_HITS };
|
||||
});
|
||||
console.log(` flights count=${primed.fl} military count=${primed.mil} | shim hits opensky=${primed.hits.opensky} mil=${primed.hits.mil}`);
|
||||
const hasMilitary = primed.mil !== null;
|
||||
if (!hasMilitary) console.log(` [SKIP] the military rows — no military layer on ${ENGINE}`);
|
||||
record('B1 ingest: synthetic planes in both layers',
|
||||
primed.fl === SPRITES.flights.length && primed.mil === SPRITES.military.length,
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${primed.mil}/${SPRITES.military.length}`);
|
||||
if (!(primed.fl > 0 && primed.mil > 0)) { finish(); return; }
|
||||
primed.fl === SPRITES.flights.length && (!hasMilitary || primed.mil === SPRITES.military.length),
|
||||
`flights=${primed.fl}/${SPRITES.flights.length} military=${hasMilitary ? `${primed.mil}/${SPRITES.military.length}` : 'n/a'}`);
|
||||
if (!(primed.fl > 0 && (!hasMilitary || primed.mil > 0))) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
// B2 — billboard glyph + scale per class (machine check)
|
||||
@@ -379,37 +370,38 @@ async function main() {
|
||||
let scaleBad = [];
|
||||
for (const [id, klass] of expectedFlights) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
for (const [id, klass] of expectedMilitary) {
|
||||
for (const [id, klass] of hasMilitary ? expectedMilitary : []) {
|
||||
const bb = bbById.get(id);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantImg = [aircraftIcon(klass), aircraftIcon(klass, 192)];
|
||||
const wantScale = MIL_BILLBOARD_SCALE * (CLASS_SCALE_2D[klass] || 1);
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !wantImg.includes(bb.image)) glyphBad.push(`${id}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${id}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('B2a: every synthetic billboard has its class glyph (data-URI match)',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + expectedMilitary.size} billboards matched`);
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expectedFlights.size + (hasMilitary ? expectedMilitary.size : 0)} billboards matched`);
|
||||
record('B2b: every synthetic billboard has its class scale',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact');
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...expectedMilitary.values()].map((k) => aircraftIcon(k)));
|
||||
record('B2c: all 8 classes exercised with 8 distinct glyphs',
|
||||
distinctGlyphs.size === 8, `${distinctGlyphs.size} distinct data URIs`);
|
||||
const distinctGlyphs = new Set([...expectedFlights.values(), ...(hasMilitary ? expectedMilitary.values() : [])].map((k) => aircraftIcon(k)));
|
||||
const wantGlyphs = hasMilitary ? 8 : new Set(expectedFlights.values()).size;
|
||||
record(`B2c: all ${wantGlyphs} classes exercised with ${wantGlyphs} distinct glyphs`,
|
||||
distinctGlyphs.size === wantGlyphs, `${distinctGlyphs.size} distinct data URIs`);
|
||||
|
||||
// ========================================================================
|
||||
// 2D screenshots
|
||||
// ========================================================================
|
||||
console.log('\n2D screenshots → qa-shots/b5/');
|
||||
console.log('\n2D screenshots → qa-shots/aircraft-sprites/');
|
||||
const topDown = async (lat, lon, height) => {
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
await page.evaluate(async ({ lat, lon, height }) => {
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const geo = await import('/src/geo/index.js');
|
||||
camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat, lon, height });
|
||||
@@ -422,6 +414,7 @@ async function main() {
|
||||
const zoomToRange = async (want) => {
|
||||
await page.evaluate((want) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) return; // the engine follow keeps its own range (screenshots only)
|
||||
const p = v.camera.position;
|
||||
const d = Math.hypot(p.x, p.y, p.z);
|
||||
if (d > 1e6) return; // not in a tracked reference frame — leave it alone
|
||||
@@ -436,19 +429,21 @@ async function main() {
|
||||
const flRowLat = await rowLat('flights', 2, 30);
|
||||
const milRowLat = await rowLat('military', 4, 15);
|
||||
await topDown((flRowLat + milRowLat) / 2, ROW_LON0 + 3 * ROW_STEP_DEG, 16000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-spread-bothrows.png') });
|
||||
console.log(' saved b5-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-spread-bothrows.png') });
|
||||
console.log(' saved aircraft-sprites-2d-spread-bothrows.png (flights row top, military row bottom)');
|
||||
|
||||
// Row widths: military spans 7 steps (~8.1 km) → frame ~13.9 km at 12 km up;
|
||||
// flights spans 5 steps (~5.8 km, +caa004's eastward drift) → ~10.4 km at 9 km up.
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-military-row.png') });
|
||||
console.log(' saved b5-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
if (hasMilitary) {
|
||||
await topDown(await rowLat('military', 4, 15), ROW_LON0 + 3.5 * ROW_STEP_DEG, 17000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-military-row.png') });
|
||||
console.log(' saved aircraft-sprites-2d-military-row.png (8 classes, amber)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('military', 15, 'ft'))}`);
|
||||
}
|
||||
|
||||
await topDown(await rowLat('flights', 2, 30), ROW_LON0 + 2.5 * ROW_STEP_DEG, 12000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-flights-row.png') });
|
||||
console.log(' saved b5-2d-flights-row.png (6 category classes, white)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-flights-row.png') });
|
||||
console.log(' saved aircraft-sprites-2d-flights-row.png (6 category classes, white)');
|
||||
console.log(` glyph positions: ${await page.evaluate(() => window.__planeCanvasCoords('flights', 30, 'm'))}`);
|
||||
|
||||
// Tracked tint: commercial tracked plane goes cyan; neighbors stay white.
|
||||
@@ -456,11 +451,11 @@ async function main() {
|
||||
window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao);
|
||||
}, 'caa002');
|
||||
await sleep(3000);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-2d-tracked-cyan.png') });
|
||||
console.log(' saved b5-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-2d-tracked-cyan.png') });
|
||||
console.log(' saved aircraft-sprites-2d-tracked-cyan.png (tracked airliner cyan, follow camera)');
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — flights cluster
|
||||
// Model scale and 3D screenshots — flights cluster
|
||||
// ========================================================================
|
||||
console.log('\n3D — flights cluster (airplane.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
@@ -497,16 +492,19 @@ async function main() {
|
||||
|
||||
await sleep(1500);
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-mid.png') });
|
||||
console.log(' saved b5-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-mid.png') });
|
||||
console.log(' saved aircraft-sprites-3d-flights-mid.png (900 m range — near the min-pixel clamp)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-flights-close.png') });
|
||||
console.log(' saved b5-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-flights-close.png') });
|
||||
console.log(' saved aircraft-sprites-3d-flights-close.png (450 m range: widebody left, airliner center, fastjet right)');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// B3 + 3D screenshots — military cluster
|
||||
// Model scale and 3D screenshots — military cluster
|
||||
// ========================================================================
|
||||
if (!hasMilitary) {
|
||||
console.log(` [SKIP] 3D military — no military layer on ${ENGINE}`);
|
||||
} else {
|
||||
console.log('\n3D — military cluster (jet.glb, per-class scale)');
|
||||
await page.evaluate(() => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -545,11 +543,12 @@ async function main() {
|
||||
// jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the
|
||||
// flights cluster (close zoom no longer puts the camera inside a model).
|
||||
await zoomToRange(900);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') });
|
||||
console.log(' saved b5-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-far.png') });
|
||||
console.log(' saved aircraft-sprites-3d-military-far.png (900 m range: C130/C17 left, F16 center, B744 right)');
|
||||
await zoomToRange(450);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-close.png') });
|
||||
console.log(' saved b5-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, 'aircraft-sprites-3d-military-close.png') });
|
||||
console.log(' saved aircraft-sprites-3d-military-close.png (450 m range: C17 left, F16 center, B744 right)');
|
||||
}
|
||||
}
|
||||
|
||||
record('B4: no console errors during QA run', consoleErrors.length === 0,
|
||||
@@ -0,0 +1,617 @@
|
||||
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from '../src/app/layers/directory.js';
|
||||
import {
|
||||
assertPreviewPortAvailable,
|
||||
createStrict,
|
||||
ENGINES,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
// The strict acceptance: every browser scenario against one keyless local
|
||||
// preview, under the strict network policy (scripts/qa-strict-harness.mjs),
|
||||
// writing <output>/acceptance.json, one log and one assertion manifest per
|
||||
// scenario.
|
||||
//
|
||||
// node scripts/qa-all-layer-switches.mjs http://127.0.0.1:4208 <output>
|
||||
// [--serve[=<commit>]] build <commit> (default HEAD) keyless in a clean
|
||||
// worktree and serve it with every .env key blank
|
||||
// [--scratch=DIR] where --serve puts its checkout and build
|
||||
// [--only=a,b] run only these labels
|
||||
// [--quick] the soak with 2 runs of 10 switches and 100
|
||||
// switches for the long run
|
||||
//
|
||||
// The run fails when a scenario fails, when any catalog layer (read from
|
||||
// the live page and cross-checked with src/app/layers/directory.js) has no
|
||||
// assertion on either engine, or when any network policy violation shows.
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const output = process.argv[3]?.startsWith('--')
|
||||
? mkdtempSync(join(tmpdir(), 'gev-acceptance-'))
|
||||
: resolve(process.argv[3] ?? mkdtempSync(join(tmpdir(), 'gev-acceptance-')));
|
||||
const flag = (name) =>
|
||||
process.argv
|
||||
.find((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`))
|
||||
?.split('=')[1] ?? (process.argv.includes(`--${name}`) ? true : null);
|
||||
const only = flag('only') ? String(flag('only')).split(',') : null;
|
||||
const quick = Boolean(flag('quick'));
|
||||
mkdirSync(join(output, 'evidence'), { recursive: true });
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
const dirty = () =>
|
||||
git('status', '--porcelain')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3));
|
||||
|
||||
// Fail once, with the fix, instead of once per scenario.
|
||||
let chrome;
|
||||
try {
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
chrome = await browser.version();
|
||||
await browser.close();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Puppeteer cannot start Chrome: ' +
|
||||
String(error?.message)
|
||||
.split('\n')[0]
|
||||
.replace(/ This can occur.*/, '') +
|
||||
'\nPoint PUPPETEER_EXECUTABLE_PATH at an installed Chrome, for example\n' +
|
||||
' PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
|
||||
'or download the pinned build with `npx puppeteer browsers install chrome`.',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Every name the checkout's .env files define, plus key-like names in this
|
||||
// environment: all blank for the build and the preview.
|
||||
const KEY_LIKE = /(_KEY|_TOKEN|_SECRET|_CLIENT_ID|PASSWORD)$/;
|
||||
/** The [name, value] pairs of the checkout's .env and .env.local, in order. */
|
||||
const dotenv = () =>
|
||||
['.env', '.env.local']
|
||||
.filter((file) => existsSync(join(root, file)))
|
||||
.flatMap((file) => readFileSync(join(root, file), 'utf8').split('\n'))
|
||||
.map((line) => line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)/))
|
||||
.filter(Boolean)
|
||||
.map(([, name, raw]) => [name, raw.trim().replace(/^["']|["']$/g, '')]);
|
||||
const dotenvNames = () => [...new Set(dotenv().map(([name]) => name))].sort();
|
||||
const envNames = () =>
|
||||
[
|
||||
...new Set([
|
||||
...dotenvNames(),
|
||||
...Object.keys(process.env).filter((name) => KEY_LIKE.test(name)),
|
||||
]),
|
||||
].sort();
|
||||
// Credentials only (model names and URLs are settings the code may also
|
||||
// carry as defaults).
|
||||
const envValues = () =>
|
||||
dotenv()
|
||||
.filter(([name, value]) => KEY_LIKE.test(name) && value.length >= 8)
|
||||
.map(([, value]) => value);
|
||||
const scrubbed = () => ({
|
||||
...process.env,
|
||||
...Object.fromEntries(envNames().map((name) => [name, ''])),
|
||||
});
|
||||
|
||||
// The report names no local directory: the output, the served checkout and
|
||||
// this repository become placeholders (logs carry stack traces).
|
||||
const machinePaths = (text) => {
|
||||
let out = String(text);
|
||||
for (const [path, name] of [
|
||||
[served?.scratch, '<scratch>'],
|
||||
[output, '<output>'],
|
||||
[root.replace(/\/$/, ''), '<repo>'],
|
||||
])
|
||||
if (path) out = out.split(path).join(name);
|
||||
return out;
|
||||
};
|
||||
let served = null;
|
||||
let preview = null;
|
||||
const cleanup = () => {
|
||||
if (preview && !preview.killed) preview.kill('SIGTERM');
|
||||
if (served?.worktree)
|
||||
try {
|
||||
git('worktree', 'remove', '--force', served.worktree);
|
||||
served.worktree = null;
|
||||
} catch (error) {
|
||||
console.error('Could not remove', served.worktree, error.message);
|
||||
}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', () => process.exit(130));
|
||||
|
||||
if (flag('serve')) {
|
||||
const commit = git(
|
||||
'rev-parse',
|
||||
flag('serve') === true ? 'HEAD' : String(flag('serve')),
|
||||
).trim();
|
||||
// --scratch=DIR keeps the checkout and build out of the system temp.
|
||||
const scratchRoot = flag('scratch')
|
||||
? resolve(String(flag('scratch')))
|
||||
: tmpdir();
|
||||
mkdirSync(scratchRoot, { recursive: true });
|
||||
await assertPreviewPortAvailable(base.href);
|
||||
const scratch = mkdtempSync(join(scratchRoot, 'gev-acceptance-build-'));
|
||||
const worktree = join(scratch, 'checkout');
|
||||
const dist = join(scratch, 'dist');
|
||||
git('worktree', 'add', '--detach', worktree, commit);
|
||||
symlinkSync(join(root, 'node_modules'), join(worktree, 'node_modules'));
|
||||
served = {
|
||||
scratch,
|
||||
commit,
|
||||
worktree,
|
||||
dist,
|
||||
// Names from .env files only; other key-like variables of this shell
|
||||
// are blanked too but only counted.
|
||||
blankedEnv: dotenvNames(),
|
||||
otherKeyLikeVariablesBlanked: envNames().filter(
|
||||
(name) => !dotenvNames().includes(name),
|
||||
).length,
|
||||
envFilesInCheckout: ['.env', '.env.local'].filter((f) =>
|
||||
existsSync(join(worktree, f)),
|
||||
),
|
||||
};
|
||||
console.log('Building', commit, 'keyless into', dist);
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'node_modules/vite/bin/vite.js'), 'build', '--outDir', dist],
|
||||
{ cwd: worktree, env: scrubbed(), encoding: 'utf8', maxBuffer: 1 << 26 },
|
||||
);
|
||||
if (build.status !== 0) {
|
||||
console.error(build.stdout + build.stderr);
|
||||
process.exit(2);
|
||||
}
|
||||
// No .env credential value may reach the built files.
|
||||
const values = envValues();
|
||||
let leaks = 0;
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const file = join(dir, name);
|
||||
if (statSync(file).isDirectory()) walk(file);
|
||||
else if (/\.(js|html|json|css|webmanifest)$/.test(name)) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
for (const value of values) if (text.includes(value)) leaks++;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
served.distCredentialValuesFound = leaks;
|
||||
served.mainChunk = JSON.parse(
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[join(root, 'scripts/check-main-chunk.mjs'), `--dist=${dist}`, '--json'],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
).stdout || 'null',
|
||||
);
|
||||
preview = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, 'node_modules/vite/bin/vite.js'),
|
||||
'preview',
|
||||
'--outDir',
|
||||
dist,
|
||||
'--host',
|
||||
base.hostname,
|
||||
'--port',
|
||||
base.port,
|
||||
'--strictPort',
|
||||
],
|
||||
{ cwd: worktree, env: scrubbed(), stdio: 'ignore' },
|
||||
);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
if ((await fetch(base.origin + '/')).ok) break;
|
||||
} catch {
|
||||
// Not up yet.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(base.origin + '/', {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) throw Error('HTTP ' + response.status);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'No preview answers at ' + base.origin + ' (' + error.message + ').',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// The preview reports no paid key: its own keyless status routes.
|
||||
const probe = async (path) => {
|
||||
try {
|
||||
const r = await fetch(base.origin + path, {
|
||||
headers: { 'Sec-Fetch-Site': 'same-origin' },
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => null) };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
const keys = {
|
||||
openaiHudSummary: await probe('/api/openai/hud-summary/status'),
|
||||
firms: await probe('/api/firms/status'),
|
||||
tomtom: await probe('/api/tomtom/status'),
|
||||
mapillary: await probe('/api/mapillary/status'),
|
||||
};
|
||||
const paidKeysConfigured = [
|
||||
keys.openaiHudSummary.body?.configured !== false && 'OpenAI',
|
||||
keys.firms.body?.hasKey !== false && 'FIRMS',
|
||||
keys.tomtom.body?.hasKey !== false && 'TomTom',
|
||||
keys.mapillary.body?.configured !== false && 'Mapillary',
|
||||
].filter(Boolean);
|
||||
|
||||
const dirtyFiles = dirty();
|
||||
const soakRuns = quick
|
||||
? ['--runs=2', '--swaps=10']
|
||||
: ['--runs=6', '--swaps=50'];
|
||||
const longRun = quick ? '--swaps=100' : '--swaps=500';
|
||||
// label, script, layers, extra arguments, timeout in seconds.
|
||||
const runs = [
|
||||
['engine-switch', 'engine-switch', ['earthquakes']],
|
||||
['director-engine-switch', 'director-engine-switch', []],
|
||||
['layer-handoff', 'layer-handoff', ['flights']],
|
||||
['layer-handoff--military', 'layer-handoff', ['military'], ['--military']],
|
||||
[
|
||||
'ground-layer-switch',
|
||||
'ground-layer-switch',
|
||||
['ais-live-vessels', 'air-quality', 'gnss-integrity', 'fire-perimeters'],
|
||||
],
|
||||
[
|
||||
'contact-switch',
|
||||
'contact-switch',
|
||||
['military-awareness', 'military-installations'],
|
||||
],
|
||||
['local-adsb-engine-switch', 'local-adsb-engine-switch', ['local-adsb']],
|
||||
['ghostmaps-switch', 'ghostmaps-switch', ['s2-ghostmaps']],
|
||||
['wildlife-switch', 'wildlife-switch', ['wildlife-sightings']],
|
||||
[
|
||||
'open-layers-switch',
|
||||
'open-layers-switch',
|
||||
['drone-airspace', 'osm-places', 'sea-marks', 'ski-pistes'],
|
||||
],
|
||||
[
|
||||
'weather-engine-switch',
|
||||
'weather-engine-switch',
|
||||
[
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
],
|
||||
],
|
||||
['wind-engine-switch', 'wind-engine-switch', ['wind']],
|
||||
[
|
||||
'infrastructure-engine-switch',
|
||||
'infrastructure-engine-switch',
|
||||
['local-dams', 'local-datacenters'],
|
||||
],
|
||||
[
|
||||
'submarine-cables-engine-switch',
|
||||
'submarine-cables-engine-switch',
|
||||
['telegeography-submarine-cables'],
|
||||
],
|
||||
['directions-engine-switch', 'directions-engine-switch', ['directions']],
|
||||
['nepal-locator-switch', 'nepal-locator-switch', ['bhote-koshi-locator']],
|
||||
['nepal-event-switch', 'nepal-event-switch', ['bhote-koshi-2026']],
|
||||
['traffic-engine-switch', 'traffic-engine-switch', ['traffic']],
|
||||
[
|
||||
'traffic-engine-switch--simulation',
|
||||
'traffic-engine-switch',
|
||||
['traffic'],
|
||||
['--simulation'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
],
|
||||
[
|
||||
'street-level-engine-switch--panoramax',
|
||||
'street-level-engine-switch',
|
||||
['street-level'],
|
||||
['--panoramax'],
|
||||
],
|
||||
['radio-engine-switch', 'radio-engine-switch', ['radio']],
|
||||
['imagery-contract', 'imagery-contract', ['recent-imagery']],
|
||||
['fire-camera-switch', 'fire-camera-switch', ['alpr-cameras']],
|
||||
[
|
||||
'fire-camera-switch--fires',
|
||||
'fire-camera-switch',
|
||||
['local-firms'],
|
||||
['--fires'],
|
||||
],
|
||||
['urban-engine-switch', 'urban-engine-switch', ['bikeshare']],
|
||||
[
|
||||
'urban-engine-switch--transit',
|
||||
'urban-engine-switch',
|
||||
['transit'],
|
||||
['--transit'],
|
||||
],
|
||||
['cctv-engine-switch', 'cctv-engine-switch', ['cctv']],
|
||||
['space-engine-switch', 'space-engine-switch', ['satellites']],
|
||||
[
|
||||
'space-engine-switch--missions',
|
||||
'space-engine-switch',
|
||||
['rocket-launches'],
|
||||
['--missions'],
|
||||
],
|
||||
// One application per page, and voice across a switch: these block paid
|
||||
// routes themselves but do not run under the strict harness, so they add
|
||||
// no assertion manifest and no network ledger.
|
||||
['app-once', 'app-once', [], [], 600, true],
|
||||
['voice-engine-switch', 'voice-engine-switch', [], [], 300, true],
|
||||
['strict-rapid-switch', 'strict-rapid-switch', []],
|
||||
['strict-hidden-switch', 'strict-hidden-switch', []],
|
||||
['strict-context-loss', 'strict-context-loss', []],
|
||||
['strict-parked-release', 'strict-parked-release', []],
|
||||
['strict-navara-hd', 'strict-navara-hd', []],
|
||||
['strict-failure-matrix', 'strict-failure-matrix', [], [], 600],
|
||||
['strict-cold-chunks', 'strict-cold-chunks', []],
|
||||
['strict-prod-smoke', 'strict-prod-smoke', []],
|
||||
// The app's visual styles, bloom, contact sonar, cockpit clouds, and the
|
||||
// annotations, Draw, Import and the scope mask with the celestial ring on
|
||||
// Navara (those are tools and display overlays, not catalog layers:
|
||||
// TOOL_IDS).
|
||||
['navara-visual-styles', 'navara-visual-styles', []],
|
||||
['navara-bloom', 'navara-bloom', []],
|
||||
['navara-sonar', 'navara-sonar', []],
|
||||
['navara-cockpit-clouds', 'navara-cockpit-clouds', []],
|
||||
['navara-annotations', 'navara-annotations', ['annotations']],
|
||||
['navara-draw', 'navara-draw', ['draw']],
|
||||
['navara-geo-import', 'navara-geo-import', ['geo-import']],
|
||||
['navara-scope', 'navara-scope', ['scope-mask', 'celestial-ring']],
|
||||
['strict-soak', 'strict-soak', [], soakRuns, 1200],
|
||||
[
|
||||
'strict-soak--long',
|
||||
'strict-soak',
|
||||
[],
|
||||
['--runs=1', longRun, '--assert'],
|
||||
1800,
|
||||
],
|
||||
];
|
||||
// Ids a scenario covers that are tools, not catalog layers: their
|
||||
// assertions are recorded, but the catalog comparison leaves them out.
|
||||
const TOOL_IDS = new Set([
|
||||
'annotations',
|
||||
'draw',
|
||||
'geo-import',
|
||||
'scope-mask',
|
||||
'celestial-ring',
|
||||
]);
|
||||
const scenarioIds = [
|
||||
...new Set(
|
||||
runs.flatMap(([, , ids]) => ids).filter((id) => !TOOL_IDS.has(id)),
|
||||
),
|
||||
].sort();
|
||||
const directoryIds = APPLICATION_LAYER_DIRECTORY.map((l) => l.id).sort();
|
||||
|
||||
// The catalog as the live page has it, on both engines.
|
||||
const liveCatalog = {};
|
||||
{
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
name: 'catalog',
|
||||
});
|
||||
const browser = await strict.launch();
|
||||
try {
|
||||
for (const engine of ENGINES) {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(
|
||||
`${base.origin}/?engine=${engine}&welcome=0#v=2&lat=30.27&lon=-97.74&alt=12000&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await waitForEngine(page, engine);
|
||||
liveCatalog[engine] = await page.evaluate(() =>
|
||||
[...window.__godsEyeView.dataManager.layers.keys()].sort(),
|
||||
);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
liveCatalog.violations = strict.ledger.violations;
|
||||
}
|
||||
|
||||
const scenarios = [];
|
||||
const report = {
|
||||
commit: git('rev-parse', 'HEAD').trim(),
|
||||
dirty: dirtyFiles.length > 0,
|
||||
dirtyFiles,
|
||||
dirtyFilesAtEnd: null,
|
||||
chrome,
|
||||
puppeteerExecutable: process.env.PUPPETEER_EXECUTABLE_PATH || null,
|
||||
node: process.version,
|
||||
platform: process.platform + ' ' + process.arch,
|
||||
baseUrl: base.origin,
|
||||
preview: served
|
||||
? {
|
||||
servedByRunner: true,
|
||||
appCommit: served.commit,
|
||||
appCheckoutClean: true,
|
||||
scenariosFrom: 'the same clean checkout',
|
||||
envFilesInCheckout: served.envFilesInCheckout,
|
||||
blankedEnv: served.blankedEnv,
|
||||
otherKeyLikeVariablesBlanked: served.otherKeyLikeVariablesBlanked,
|
||||
distCredentialValuesFound: served.distCredentialValuesFound,
|
||||
}
|
||||
: { servedByRunner: false },
|
||||
keyProbes: keys,
|
||||
paidKeysConfigured,
|
||||
mainChunk: served?.mainChunk ?? null,
|
||||
networkPolicy: {
|
||||
blockedFirst:
|
||||
'Google (*.googleapis.com, *.google.com, maps.google.*, *.gstatic.com), Cesium ion (*.cesium.com), preview /api/openai/*, /api/realtime/*, /api/google/*',
|
||||
allowed:
|
||||
'the preview origin, each scenario’s declared fixture URLs, keyless probes answered by the harness, and the free tile hosts answered from the disk cache',
|
||||
keylessProbes: (await import('./qa-map-policy.mjs')).KEYLESS_PROBES.map(
|
||||
(p) => p.pattern,
|
||||
),
|
||||
tileHosts: (await import('./qa-map-policy.mjs')).STRICT_TILE_HOSTS,
|
||||
},
|
||||
catalog: {
|
||||
directory: directoryIds,
|
||||
live: liveCatalog,
|
||||
scenarios: scenarioIds,
|
||||
},
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
networkViolations: 0,
|
||||
coverage: null,
|
||||
uncovered: null,
|
||||
scenarios,
|
||||
};
|
||||
const save = () =>
|
||||
writeFileSync(
|
||||
join(output, 'acceptance.json'),
|
||||
machinePaths(JSON.stringify(report, null, 2)) + '\n',
|
||||
);
|
||||
save();
|
||||
|
||||
for (const [label, name, layers, extra = [], seconds = 300, external] of runs) {
|
||||
if (only && !only.includes(label)) continue;
|
||||
const script = 'scripts/qa-' + name + '.mjs';
|
||||
const evidence = join(output, 'evidence', label + '.json');
|
||||
rmSync(evidence, { force: true });
|
||||
const args = [base.origin, ...extra];
|
||||
if (external) args.splice(0, 1, base.origin + '/');
|
||||
else args.push(`--layers=${layers.join(',')}`, `--evidence=${evidence}`);
|
||||
const start = Date.now();
|
||||
// With --serve the scenarios run from the same clean checkout the app was
|
||||
// built from, so other work in this tree cannot change them.
|
||||
const scripts = served?.worktree ?? root;
|
||||
const run = spawnSync(process.execPath, [join(scripts, script), ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: seconds * 1000,
|
||||
maxBuffer: 1 << 26,
|
||||
env: scrubbed(),
|
||||
});
|
||||
const status =
|
||||
run.status === 0
|
||||
? 'pass'
|
||||
: run.error?.code === 'ETIMEDOUT'
|
||||
? 'timeout'
|
||||
: 'fail';
|
||||
const log = machinePaths(
|
||||
(run.stdout || '') + (run.stderr || '') + (run.error?.message || ''),
|
||||
);
|
||||
writeFileSync(join(output, label + '.log'), log);
|
||||
const manifest = existsSync(evidence)
|
||||
? JSON.parse(machinePaths(readFileSync(evidence, 'utf8')))
|
||||
: null;
|
||||
if (manifest)
|
||||
writeFileSync(evidence, JSON.stringify(manifest, null, 2) + '\n');
|
||||
scenarios.push({
|
||||
label,
|
||||
script,
|
||||
args: args.map((arg) =>
|
||||
arg.includes(output) ? arg.replace(output, '<output>') : arg,
|
||||
),
|
||||
layers,
|
||||
external: Boolean(external),
|
||||
status,
|
||||
exitCode: run.status,
|
||||
seconds: Math.round((Date.now() - start) / 100) / 10,
|
||||
log: label + '.log',
|
||||
logSha256: createHash('sha256').update(log).digest('hex'),
|
||||
evidence: manifest ? 'evidence/' + label + '.json' : null,
|
||||
assertions: manifest?.assertions.length ?? 0,
|
||||
coverage: manifest?.coverage ?? null,
|
||||
picks: manifest?.picks ?? [],
|
||||
network: manifest
|
||||
? {
|
||||
requests: manifest.network.requests,
|
||||
violations: manifest.network.violations,
|
||||
external: manifest.network.external,
|
||||
tileCache: manifest.network.tileCache,
|
||||
probes: manifest.network.probes,
|
||||
routes: manifest.network.routes,
|
||||
previewApi: manifest.network.previewApi,
|
||||
fixtures: manifest.network.fixtures,
|
||||
}
|
||||
: null,
|
||||
contracts: manifest?.contracts ?? null,
|
||||
error: manifest?.error ?? null,
|
||||
});
|
||||
if (manifest?.soak) scenarios.at(-1).soak = manifest.soak;
|
||||
report.passed = scenarios.filter((row) => row.status === 'pass').length;
|
||||
report.failed = scenarios.length - report.passed;
|
||||
report.networkViolations =
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.network?.violations.length ?? 0),
|
||||
0,
|
||||
) + liveCatalog.violations.length;
|
||||
save();
|
||||
console.log(status.toUpperCase(), label, scenarios.at(-1).seconds);
|
||||
}
|
||||
|
||||
// Coverage that cannot be faked: every catalog id needs an assertion on each
|
||||
// engine from some scenario's manifest.
|
||||
const catalogIds = [
|
||||
...new Set([...directoryIds, ...(liveCatalog.navara ?? [])]),
|
||||
].sort();
|
||||
report.coverage = Object.fromEntries(
|
||||
catalogIds.map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
ENGINES.map((engine) => [
|
||||
engine,
|
||||
scenarios.reduce(
|
||||
(sum, row) => sum + (row.coverage?.[id]?.[engine] ?? 0),
|
||||
0,
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
report.uncovered = Object.entries(report.coverage).flatMap(([id, counts]) =>
|
||||
ENGINES.filter((engine) => !counts[engine]).map(
|
||||
(engine) => `${id}@${engine}`,
|
||||
),
|
||||
);
|
||||
report.catalogMatches =
|
||||
JSON.stringify(liveCatalog.navara) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(liveCatalog.cesium) === JSON.stringify(directoryIds) &&
|
||||
JSON.stringify(scenarioIds) === JSON.stringify(directoryIds);
|
||||
report.finishedAt = new Date().toISOString();
|
||||
report.dirtyFilesAtEnd = dirty();
|
||||
save();
|
||||
console.log(
|
||||
`${catalogIds.length} catalog layers, ${report.uncovered.length} engine slots without an assertion, ` +
|
||||
`${report.networkViolations} network violations, ${report.passed}/${scenarios.length} scenarios, ` +
|
||||
`paid keys configured: ${paidKeysConfigured.join(', ') || 'none'}. ${join(output, 'acceptance.json')}`,
|
||||
);
|
||||
if (
|
||||
report.failed ||
|
||||
(!only && report.uncovered.length) ||
|
||||
report.networkViolations ||
|
||||
paidKeysConfigured.length ||
|
||||
!report.catalogMatches
|
||||
)
|
||||
process.exitCode = 1;
|
||||
// The preview child would keep this process alive: stop it and drop the
|
||||
// checkout now.
|
||||
cleanup();
|
||||
+93
-20
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* qa-alpr-journey.mjs — ALPR cameras on a realistic Austin camera journey.
|
||||
*
|
||||
* Run: node scripts/qa-alpr-journey.mjs http://localhost:4173 [--headful]
|
||||
* Run: node scripts/qa-alpr-journey.mjs <dev-server-url> [--fixture]
|
||||
* [--passes off,on,on] [--out qa-shots/alpr-journey] [--no-assert]
|
||||
*
|
||||
* Drives the same eased camera path several times: a neighbourhood by Camp
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Per stop it records: time from the camera stopping to cameras shown (or an
|
||||
* explicit "zoom in" state), the camera count and row status, rAF interval and
|
||||
* Cesium render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* engine render CPU time (mean / p50 / p95) while moving and while holding, and
|
||||
* marker stability while the camera is still (camera-set changes after the stop
|
||||
* has settled). Asserts, unless --no-assert: neighbourhood and downtown show
|
||||
* cameras within 2 s on the warm pass; the whole-city stop shows cameras or a
|
||||
@@ -39,7 +39,10 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import sharp from 'sharp';
|
||||
import { installAlprFixture } from './fixtures/layers/alpr.mjs';
|
||||
import { redact, isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
|
||||
import {
|
||||
createScreencast,
|
||||
frameStats,
|
||||
@@ -60,12 +63,20 @@ const option = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const appUrl =
|
||||
argv.find((arg) => /^https?:/.test(arg)) || 'http://localhost:4173';
|
||||
const appUrl = argv.find((arg) => /^https?:/.test(arg));
|
||||
if (!appUrl) throw new Error('An explicit dev-server URL is required');
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
new URL(appUrl).port,
|
||||
)
|
||||
)
|
||||
throw new Error('ALPR QA may not use a reserved port');
|
||||
const outDir = path.resolve(option('--out', 'qa-shots/alpr-journey'));
|
||||
const passes = option('--passes', 'off,on,on').split(',');
|
||||
const assertOn = !argv.includes('--no-assert');
|
||||
const dropRenders = argv.includes('--self-test-drop-renders');
|
||||
if (dropRenders && new URL(appUrl).searchParams.get('engine') === 'navara')
|
||||
throw new Error('The legacy render-drop self-test requires Cesium');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const cyanCount = async (file) => {
|
||||
@@ -170,6 +181,8 @@ const result = {
|
||||
startedAt: new Date().toISOString(),
|
||||
passes: [],
|
||||
consoleErrors: [],
|
||||
expectedPreviewErrors: [],
|
||||
failedRequests: [],
|
||||
failures: [],
|
||||
};
|
||||
const fail = (message) => {
|
||||
@@ -179,14 +192,44 @@ const fail = (message) => {
|
||||
let page;
|
||||
try {
|
||||
page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
if (argv.includes('--fixture')) await installAlprFixture(page);
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') result.consoleErrors.push(message.text());
|
||||
if (
|
||||
isExpectedPreviewSetupError(message, {
|
||||
preview: argv.includes('--preview'),
|
||||
baseUrl: appUrl,
|
||||
})
|
||||
) {
|
||||
result.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error') {
|
||||
let resource = '(location unavailable)';
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
resource = url.origin + url.pathname;
|
||||
} catch {
|
||||
/* no resource location */
|
||||
}
|
||||
result.consoleErrors.push(redact(`${message.text()} — ${resource}`));
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) =>
|
||||
result.consoleErrors.push(redact(error.message)),
|
||||
);
|
||||
page.on('requestfailed', (request) => {
|
||||
if (result.failedRequests.length >= 200) return;
|
||||
const url = new URL(request.url());
|
||||
result.failedRequests.push({
|
||||
resource: redact(url.origin + url.pathname),
|
||||
error: request.failure()?.errorText,
|
||||
});
|
||||
});
|
||||
page.on('pageerror', (error) => result.consoleErrors.push(error.message));
|
||||
const url = new URL(appUrl);
|
||||
url.searchParams.set('welcome', '0');
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 90_000 });
|
||||
@@ -210,23 +253,17 @@ try {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get('alpr-cameras');
|
||||
const stats = entry?.module?.getStats?.() || {};
|
||||
// Layers that publish a render revision are measured by it; otherwise
|
||||
// hash the rendered entity ids and their visibility.
|
||||
// hash the layer's mover sets on the globe contract (ids and counts).
|
||||
let hash = 0;
|
||||
let shown = 0;
|
||||
if (Number.isFinite(stats.renderRevision)) {
|
||||
hash = stats.renderRevision;
|
||||
shown = stats.shown ?? stats.count ?? 0;
|
||||
} else {
|
||||
const entities =
|
||||
entry?.module && entry.enabled
|
||||
? window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'alpr-cameras',
|
||||
)[0]?.entities.values || []
|
||||
: [];
|
||||
for (const entity of entities) {
|
||||
if (entity.show === false) continue;
|
||||
shown += 1;
|
||||
const id = String(entity.id);
|
||||
} else if (entry?.module && entry.enabled) {
|
||||
for (const set of window.__godsEyeView.engine.movers.list()) {
|
||||
if (!String(set.id).startsWith('alpr-')) continue;
|
||||
shown += set.count;
|
||||
const id = `${set.id}:${set.count}`;
|
||||
for (let i = 0; i < id.length; i++)
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
@@ -252,6 +289,8 @@ try {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
if (window.__godsEyeView.engine.kind === 'navara')
|
||||
return window.__godsEyeView.engine.surface.tilesLoaded();
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
if (scene.globe.show) return scene.globe.tilesLoaded;
|
||||
for (let i = 0; i < scene.primitives.length; i++) {
|
||||
@@ -308,8 +347,31 @@ try {
|
||||
// changed state without asking for a render.
|
||||
let staleFrame = null;
|
||||
if (alpr) {
|
||||
const shown = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const shownImage = await page.screenshot({ type: 'png' });
|
||||
const shown = await cyanCount(shownImage);
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
if (engine.kind === 'navara') {
|
||||
await new Promise((resolve, reject) => {
|
||||
let count = 4;
|
||||
const timeout = setTimeout(() => {
|
||||
remove();
|
||||
reject(new Error('ALPR forced-frame deadline'));
|
||||
}, 10000);
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--count === 0) {
|
||||
remove();
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
} else
|
||||
requestAnimationFrame(() =>
|
||||
engine.render.request('alpr-qa-frame'),
|
||||
);
|
||||
});
|
||||
engine.render.request('alpr-qa-frame');
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { scene } = window.__godsEyeView.viewer;
|
||||
const mode = scene.requestRenderMode;
|
||||
scene.requestRenderMode = false;
|
||||
@@ -317,12 +379,23 @@ try {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
scene.requestRenderMode = mode;
|
||||
});
|
||||
const forced = await cyanCount(await page.screenshot({ type: 'png' }));
|
||||
const forcedImage = await page.screenshot({ type: 'png' });
|
||||
const forced = await cyanCount(forcedImage);
|
||||
staleFrame = {
|
||||
shown,
|
||||
forced,
|
||||
stale: Math.abs(forced - shown) > Math.max(8, forced * 0.15),
|
||||
};
|
||||
if (staleFrame.stale) {
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-shown.png`),
|
||||
shownImage,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${passName}-${stop.name}-forced.png`),
|
||||
forcedImage,
|
||||
);
|
||||
}
|
||||
}
|
||||
const moving = frameStats(await readFrames(page, moveStart, holdStart));
|
||||
const holding = frameStats(await readFrames(page, holdStart, holdEnd));
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { localPreview, waitForEngine } from './qa-strict-harness.mjs';
|
||||
// The application is created once per page: switching engines detaches and
|
||||
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
|
||||
// Cesium round trips and checks that every session object keeps its identity,
|
||||
// an open panel, typed text and the focused field survive, Escape still does
|
||||
// what it did before the switches, window/document listeners do not grow,
|
||||
// and a failed candidate rolls back onto the same session objects.
|
||||
//
|
||||
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
|
||||
// `--report` prints the measurements without asserting them.
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
idDifference,
|
||||
launch,
|
||||
listenerCounts,
|
||||
listenerDifference,
|
||||
openSessionPage,
|
||||
readSession,
|
||||
requestEngine,
|
||||
sessionAddress,
|
||||
switchEngine,
|
||||
} from './qa/appSessionHarness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const reportOnly = process.argv.includes('--report');
|
||||
const report = { switches: [], failures: [] };
|
||||
const check = (condition, message, detail) => {
|
||||
if (condition) return;
|
||||
report.failures.push({ message, detail });
|
||||
if (!reportOnly)
|
||||
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
|
||||
};
|
||||
|
||||
const FIELD = '#location-search';
|
||||
const PANEL = 'data-panel';
|
||||
|
||||
// The page's visible UI state the switches must leave alone.
|
||||
const readUi = (page) =>
|
||||
page.evaluate(
|
||||
(field, panel) => ({
|
||||
value: document.querySelector(field)?.value ?? null,
|
||||
focused: document.activeElement?.id || null,
|
||||
panelOpen: !document
|
||||
.getElementById(panel)
|
||||
?.classList.contains('collapsed'),
|
||||
}),
|
||||
FIELD,
|
||||
PANEL,
|
||||
);
|
||||
|
||||
// Focus the field, press Escape and read what changed; then put the state
|
||||
// back. The same press before and after the switches must have the same
|
||||
// effect.
|
||||
async function escapeEffect(page) {
|
||||
await page.focus(FIELD);
|
||||
const before = await readUi(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const after = await readUi(page);
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
async function prepareUi(page) {
|
||||
await page.evaluate((panel) => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
style.setPanelCollapsed(panel, false, { explicit: true });
|
||||
style.setPanelCollapsed('location-bar', false, { explicit: true });
|
||||
}, PANEL);
|
||||
await page.waitForFunction(
|
||||
(field) => document.querySelector(field)?.offsetParent !== null,
|
||||
{},
|
||||
FIELD,
|
||||
);
|
||||
await page.evaluate((field) => {
|
||||
const input = document.querySelector(field);
|
||||
input.focus();
|
||||
input.value = 'Reykjav';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, FIELD);
|
||||
}
|
||||
|
||||
// The session's tools: a mark on the annotation board, an imported file and
|
||||
// the Draw shape, set on Cesium before the switches.
|
||||
async function prepareTools(page) {
|
||||
return page.evaluate(async () => {
|
||||
const annotated = await window.__gevAnnotations.annotate([
|
||||
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
||||
]);
|
||||
const file = new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'QA area' },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-71.07, 42.35],
|
||||
[-71.05, 42.35],
|
||||
[-71.05, 42.36],
|
||||
[-71.07, 42.35],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
'qa-area.geojson',
|
||||
{ type: 'application/geo+json' },
|
||||
);
|
||||
const imported = await window.__gevGeoImport.importFiles([file]);
|
||||
window.__gevDrawTool.setShape('line');
|
||||
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
||||
});
|
||||
}
|
||||
|
||||
const readTools = (page) =>
|
||||
page.evaluate(() => ({
|
||||
marks: window.__gevAnnotations?.count() ?? null,
|
||||
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
||||
files: window.__gevGeoImport?.list().length ?? null,
|
||||
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
||||
drawShape: window.__gevDrawTool?.shape ?? null,
|
||||
}));
|
||||
|
||||
// Listeners whose difference is the parked renderer's: the Navara engine
|
||||
// keeps its own input, resize and attribution listeners while it is parked
|
||||
// for a switch back (scripts that are not the session's).
|
||||
const PARKED_RENDERER = /@navara-engine\.js$/;
|
||||
|
||||
const browser = await launch();
|
||||
try {
|
||||
const { page, errors } = await openSessionPage(
|
||||
browser,
|
||||
sessionAddress(base, { engine: 'cesium' }),
|
||||
);
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
// Let the first boot's deferred listeners land before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const boot = await readSession(page);
|
||||
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||
report.boot = boot;
|
||||
report.toolsPrepared = await prepareTools(page);
|
||||
check(
|
||||
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
||||
'the tools could not be prepared',
|
||||
report.toolsPrepared,
|
||||
);
|
||||
// Let the imported file finish drawing.
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
);
|
||||
const toolsBefore = await readTools(page);
|
||||
report.toolsBefore = toolsBefore;
|
||||
|
||||
await prepareUi(page);
|
||||
const escapeBefore = await escapeEffect(page);
|
||||
report.escapeBefore = escapeBefore;
|
||||
await prepareUi(page);
|
||||
const uiBefore = await readUi(page);
|
||||
report.uiBefore = uiBefore;
|
||||
|
||||
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
|
||||
// Dev guard: once an engine is parked, nothing may move its camera or
|
||||
// ask it to draw until it is attached again (its own resume aside).
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedCalls = [];
|
||||
window.__qaWatchParked = (engine) => {
|
||||
if (!engine || engine.__qaWatched) return;
|
||||
engine.__qaWatched = true;
|
||||
const watch = (owner, names, label) => {
|
||||
for (const name of names) {
|
||||
const original = owner[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
owner[name] = function (...args) {
|
||||
const parked = window.__godsEyeView?.engine !== engine;
|
||||
const resume = name === 'request' && args[0] === 'engine-resume';
|
||||
if (parked && !resume && !window.__qaSwitching)
|
||||
window.__qaParkedCalls.push({
|
||||
call: `${engine.kind}.${label}.${name}`,
|
||||
reason: typeof args[0] === 'string' ? args[0] : null,
|
||||
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
};
|
||||
watch(
|
||||
engine.camera,
|
||||
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
|
||||
'camera',
|
||||
);
|
||||
watch(engine.render, ['request', 'setContinuous'], 'render');
|
||||
};
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
for (const kind of sequence) {
|
||||
const started = Date.now();
|
||||
// The switch itself parks and resumes engines; only the time an engine
|
||||
// sits parked is watched.
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = true;
|
||||
});
|
||||
await switchEngine(page, kind);
|
||||
await page.evaluate(() => {
|
||||
window.__qaSwitching = false;
|
||||
window.__qaWatchParked(window.__godsEyeView.engine);
|
||||
});
|
||||
const state = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
if (kind === 'navara')
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
if (report.switches.length === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||
}
|
||||
// Both engines draw the session's marks and files (Navara included).
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const tools = await readTools(page);
|
||||
check(
|
||||
tools.marks === toolsBefore.marks &&
|
||||
tools.files === toolsBefore.files &&
|
||||
tools.drawShape === toolsBefore.drawShape &&
|
||||
tools.marksDrawn === true &&
|
||||
tools.filesDrawn === 1,
|
||||
`the session tools changed on the switch to ${kind}`,
|
||||
{ toolsBefore, tools },
|
||||
);
|
||||
report.switches.push({
|
||||
kind,
|
||||
ms: Date.now() - started,
|
||||
ids: idDifference(boot.ids, state.ids),
|
||||
counters: state.counters,
|
||||
ui,
|
||||
tools,
|
||||
});
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||
`session ids changed on the switch to ${kind}`,
|
||||
idDifference(boot.ids, state.ids),
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value,
|
||||
`typed text lost on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
|
||||
check(
|
||||
ui.focused === uiBefore.focused,
|
||||
`focus moved on the switch to ${kind}`,
|
||||
ui,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const after = await readSession(page);
|
||||
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||
report.after = after;
|
||||
// After three round trips the page holds exactly the listeners it held
|
||||
// after the first boot, apart from the parked Navara renderer's own (it
|
||||
// stays alive for the switch back), which the first round trip added and
|
||||
// the later ones did not grow.
|
||||
report.listeners = listenerDifference(bootListeners, afterListeners);
|
||||
report.listenersSinceFirstRoundTrip = listenerDifference(
|
||||
report.firstRoundTrip,
|
||||
afterListeners,
|
||||
);
|
||||
const sessionListeners = Object.keys(report.listeners).filter(
|
||||
(key) => !PARKED_RENDERER.test(key),
|
||||
);
|
||||
check(
|
||||
sessionListeners.length === 0,
|
||||
'session listeners on window/document changed after three round trips',
|
||||
report.listeners,
|
||||
);
|
||||
check(
|
||||
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||
'listeners on window/document grew after the first round trip',
|
||||
report.listenersSinceFirstRoundTrip,
|
||||
);
|
||||
check(
|
||||
after.counters?.attachments === 1 + sequence.length &&
|
||||
after.counters?.detaches === sequence.length,
|
||||
'each switch detaches once and attaches once',
|
||||
after.counters,
|
||||
);
|
||||
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
|
||||
check(
|
||||
report.parkedEngineCalls.length === 0,
|
||||
'a parked engine had its camera moved or a frame requested',
|
||||
report.parkedEngineCalls,
|
||||
);
|
||||
check(
|
||||
after.counters?.applications === 1,
|
||||
'the application must be created once',
|
||||
after.counters,
|
||||
);
|
||||
|
||||
const escapeAfter = await escapeEffect(page);
|
||||
report.escapeAfter = escapeAfter;
|
||||
check(
|
||||
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
|
||||
'Escape behaves differently after the switches',
|
||||
{ escapeBefore, escapeAfter },
|
||||
);
|
||||
|
||||
// A failed candidate: the parked Navara renderer refuses to resume (as in
|
||||
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
|
||||
// session objects.
|
||||
await prepareUi(page);
|
||||
const beforeRollback = await readSession(page);
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await page.evaluate(() => {
|
||||
window.__qaRollback = false;
|
||||
const parked = window.__qaParkedNavara;
|
||||
const request = parked.render.request;
|
||||
parked.render.request = function (...args) {
|
||||
parked.render.request = request;
|
||||
window.__qaRollback = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
});
|
||||
const sabotaged = true;
|
||||
if (sabotaged) {
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaRollback === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const rolledBack = await readSession(page);
|
||||
const ui = await readUi(page);
|
||||
report.rollback = {
|
||||
ids: idDifference(beforeRollback.ids, rolledBack.ids),
|
||||
counters: rolledBack.counters,
|
||||
ui,
|
||||
history: (await page.evaluate(() => history.length)) - historyLength,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.rollback.ids).length === 0,
|
||||
'a rollback changed session ids',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
report.rollback.history === 0,
|
||||
'a rollback added a history entry',
|
||||
report.rollback,
|
||||
);
|
||||
check(
|
||||
ui.value === uiBefore.value && ui.panelOpen,
|
||||
'a rollback lost the UI state',
|
||||
ui,
|
||||
);
|
||||
// The page still switches after the rollback.
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
const final = await readSession(page);
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
|
||||
'ids changed after the rollback',
|
||||
idDifference(boot.ids, final.ids),
|
||||
);
|
||||
|
||||
// A candidate failing after the shell attached to it: a layer's
|
||||
// drawing refuses to attach on Navara. The shell lets go of the failed
|
||||
// globe and Cesium attaches again; the next switch works.
|
||||
await page.evaluate(() => {
|
||||
window.__qaDataRefused = false;
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
|
||||
const attach = module.attachRenderer;
|
||||
module.attachRenderer = function () {
|
||||
module.attachRenderer = attach;
|
||||
window.__qaDataRefused = true;
|
||||
return Promise.reject(new Error('QA data attach failure'));
|
||||
};
|
||||
});
|
||||
const beforeDataFailure = await page.evaluate(() => history.length);
|
||||
await requestEngine(page, 'navara');
|
||||
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await waitForEngine(page, 'cesium', { timeout: 90_000 });
|
||||
const afterDataFailure = await readSession(page);
|
||||
report.dataFailure = {
|
||||
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||
counters: afterDataFailure.counters,
|
||||
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
||||
};
|
||||
check(
|
||||
Object.keys(report.dataFailure.ids).length === 0 &&
|
||||
report.dataFailure.history === 0,
|
||||
'a candidate failing in the data part changed the session',
|
||||
report.dataFailure,
|
||||
);
|
||||
await switchEngine(page, 'navara');
|
||||
await switchEngine(page, 'cesium');
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
||||
.length === 0,
|
||||
'ids changed after the data-part failure',
|
||||
);
|
||||
}
|
||||
report.errors = errors;
|
||||
check(errors.length === 0, 'page errors', errors);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -23,6 +23,74 @@ export function redact(text) {
|
||||
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
||||
}
|
||||
|
||||
/** The standalone Vite preview has no setup API; opt in explicitly. */
|
||||
export function isExpectedPreviewSetupError(
|
||||
message,
|
||||
{ preview = false, baseUrl } = {},
|
||||
) {
|
||||
if (
|
||||
!preview ||
|
||||
message.type() !== 'error' ||
|
||||
!/^Failed to load resource: .*\b404\b/.test(message.text())
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
return (
|
||||
url.origin === new URL(baseUrl).origin &&
|
||||
url.pathname === '/api/setup/status'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime evidence gate shares one strict runtime verdict across its main page and
|
||||
// every share-link page. Response diagnostics never retain query credentials.
|
||||
export function watchBrowserPage(
|
||||
page,
|
||||
report,
|
||||
{ name, preview = false, baseUrl },
|
||||
) {
|
||||
const errors = (report.browserErrors ??= []);
|
||||
report.expectedPreviewErrors ??= [];
|
||||
report.failedResponses ??= [];
|
||||
(report.runtimePages ??= []).push(name);
|
||||
const resourcePath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return redact(url.origin + url.pathname);
|
||||
} catch {
|
||||
return '(location unavailable)';
|
||||
}
|
||||
};
|
||||
page.on('console', (message) => {
|
||||
if (isExpectedPreviewSetupError(message, { preview, baseUrl })) {
|
||||
report.expectedPreviewErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
if (message.type() === 'error')
|
||||
errors.push(
|
||||
`[${name}] ${redact(message.text())} — ${resourcePath(message.location()?.url)}`,
|
||||
);
|
||||
});
|
||||
for (const event of ['pageerror', 'error'])
|
||||
page.on(event, (error) =>
|
||||
errors.push(`[${name}] ${redact(error.message)}`),
|
||||
);
|
||||
page.on('response', (response) => {
|
||||
if (
|
||||
response.status() >= 400 &&
|
||||
report.failedResponses.length < EVIDENCE_LIMIT
|
||||
)
|
||||
report.failedResponses.push({
|
||||
page: name,
|
||||
status: response.status(),
|
||||
resource: resourcePath(response.url()),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function push(list, entry) {
|
||||
list.push(entry);
|
||||
if (list.length > EVIDENCE_LIMIT)
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
* a. per-frame `scene.render` cost over a ~10 s driven-orbit window
|
||||
* (mean / p50 / p95 / max — relative numbers, SwiftShader surface),
|
||||
* b. toggle-ON → first-labels-visible latency,
|
||||
* c. entity / native-label / host-entry counts,
|
||||
* c. contract set (ground paths, mover tips, stems) / host-entry counts,
|
||||
* d. parked-idle postRender fires over 5 s (render-governor honesty).
|
||||
* 2. GATE (unified build only): assert the migration contract —
|
||||
* zero native `LabelGraphics` on cable reference entities, host entries
|
||||
* no layer-owned Cesium data source (paths, pins and tips are globe
|
||||
* contract sets), host entries
|
||||
* present for the source, idle stays near-zero, and a clean OFF→ON cycle
|
||||
* (no orphan host entries after disable).
|
||||
*
|
||||
@@ -105,35 +106,48 @@ try {
|
||||
await new Promise((r) => setTimeout(r, 3_000)); // let load/labels settle
|
||||
}
|
||||
|
||||
// ── c. entity / label / host counts ───────────────────────────────────
|
||||
const counts = await page.evaluate((layerId) => {
|
||||
// ── c. drawn-set / label / host counts ────────────────────────────────
|
||||
// The layer draws through the globe contract (render/display.js): cable
|
||||
// paths in one engine.ground corridor set, landing points and reference
|
||||
// tips in mover sets, stems in engine.lines, labels on the world-overlay
|
||||
// host. It owns no Cesium DataSource or Entity any more.
|
||||
const counts = await page.evaluate((layerId, legacy) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const engine = gev.engine;
|
||||
const out = {
|
||||
dataSources: {}, nativeLabels: 0, dynamicPositionEntities: 0,
|
||||
referenceEntities: 0, hostEntries: 0, hostPainted: 0,
|
||||
layerDataSources: 0, layerEntities: 0,
|
||||
pathSets: 0, pathItems: 0, markers: 0, tipSets: 0, tips: 0, stems: 0,
|
||||
hostEntries: 0, hostPainted: 0,
|
||||
};
|
||||
for (let i = 0; i < v.dataSources.length; i++) {
|
||||
for (let i = 0; i < (v?.dataSources?.length ?? 0); i++) {
|
||||
const ds = v.dataSources.get(i);
|
||||
if (!/TeleGeography/.test(ds.name || '')) continue;
|
||||
const list = ds.entities.values;
|
||||
out.dataSources[ds.name] = list.length;
|
||||
for (const e of list) {
|
||||
if (e.label !== undefined) out.nativeLabels++;
|
||||
if (e.position && e.position.isConstant === false) out.dynamicPositionEntities++;
|
||||
if (e.polyline?.positions && e.polyline.positions.isConstant === false) {
|
||||
out.dynamicPositionEntities++;
|
||||
}
|
||||
}
|
||||
if (/References/.test(ds.name)) out.referenceEntities = list.length;
|
||||
out.layerDataSources++;
|
||||
out.layerEntities += ds.entities.values.length;
|
||||
}
|
||||
if (legacy) return out;
|
||||
for (const set of engine.ground.list()) {
|
||||
if (set.id !== `${layerId}-paths`) continue;
|
||||
out.pathSets++;
|
||||
out.pathItems += set.count;
|
||||
}
|
||||
for (const set of engine.movers.list()) {
|
||||
if (set.id === `${layerId}-markers`) out.markers += set.count;
|
||||
else if (set.id.startsWith(`${layerId}-`) && set.id.endsWith('-tips')) {
|
||||
out.tipSets++;
|
||||
out.tips += set.count;
|
||||
}
|
||||
}
|
||||
out.stems = (engine.lines.list().find((line) =>
|
||||
line.id === `${layerId}-stems` && line.shown)?.points ?? 0) / 2;
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.() || {};
|
||||
out.hostEntries = diag.entriesBySource?.[layerId] || 0;
|
||||
out.hostPainted = diag.paintedBySource?.[layerId] || 0;
|
||||
const stats = gev.dataManager.layers.get(layerId)?.module?.getStats?.() || {};
|
||||
out.referenceLabelCount = stats.referenceLabelCount ?? null;
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
}, LAYER_ID, legacy);
|
||||
report(control ? 'counts (control, cables OFF)' : 'counts with cables ON', counts);
|
||||
|
||||
// ── a. per-frame scene.render cost over a ~10 s driven orbit ─────────
|
||||
@@ -189,8 +203,10 @@ try {
|
||||
|
||||
if (!legacy && !control) {
|
||||
// ── unified-build contract gates ────────────────────────────────────
|
||||
check('zero native LabelGraphics on cable entities', counts.nativeLabels === 0, counts.nativeLabels);
|
||||
check('zero per-frame CallbackProperty cable entities', counts.dynamicPositionEntities === 0, counts.dynamicPositionEntities);
|
||||
// Paths, pins and tips belong to contract sets; labels belong to the host.
|
||||
check('no layer-owned Cesium data source or entities', counts.layerDataSources === 0 && counts.layerEntities === 0, counts);
|
||||
check('cable paths drawn as an engine.ground corridor set', counts.pathSets === 1 && counts.pathItems > 0, counts);
|
||||
check('reference tips drawn as mover sets', counts.tipSets > 0 && counts.tips > 0, counts);
|
||||
check('host has cable entries', counts.hostEntries > 0, counts.hostEntries);
|
||||
check('host painted cable labels', counts.hostPainted > 0, counts.hostPainted);
|
||||
// The cables source never republishes an unchanged cohort (unit pin), and
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2718&lon=-97.744&alt=900&pitch=-70&heading=0&map=sentinel2&l=';
|
||||
const { stdout: video } = await promisify(execFile)(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'testsrc2=size=320x180:rate=12',
|
||||
'-t',
|
||||
'60',
|
||||
'-an',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-preset',
|
||||
'ultrafast',
|
||||
'-threads',
|
||||
'1',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
'pipe:1',
|
||||
],
|
||||
{ encoding: 'buffer', maxBuffer: 16000000 },
|
||||
);
|
||||
const strict = createStrict({ origin: url.origin, layers: ['cctv'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
await installCctvFixture(p, { video });
|
||||
let catalogs = 0;
|
||||
p.on('request', (req) => {
|
||||
if (new URL(req.url()).pathname === '/api/cctv/sources') catalogs++;
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
await dataManager.setEnabled('cctv', true, { origin: 'user' });
|
||||
window.qaCctv = dataManager.layers.get('cctv').module;
|
||||
window.qaCctv.setParams({
|
||||
selectedCameraId: 'fixture-cctv-video',
|
||||
showProjection: true,
|
||||
coverageMode: 'viewshed',
|
||||
calibration: { patch: { heightM: 31, pitchDeg: 20 } },
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaCctv.getActiveVideoElement()?.videoWidth === 320 &&
|
||||
window.qaCctv.getActiveVideoElement().currentTime > 0.1,
|
||||
{ timeout: 40000 },
|
||||
);
|
||||
const first = await p.evaluate(() => {
|
||||
window.qaVideo = window.qaCctv.getActiveVideoElement();
|
||||
return window.qaVideo.getVideoPlaybackQuality().totalVideoFrames;
|
||||
});
|
||||
let previous = first;
|
||||
const count = catalogs;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(
|
||||
(n) =>
|
||||
window.qaCctv.getActiveVideoElement()?.getVideoPlaybackQuality()
|
||||
.totalVideoFrames > n,
|
||||
{},
|
||||
previous,
|
||||
);
|
||||
const v = await p.evaluate(() => ({
|
||||
same:
|
||||
window.qaCctv ===
|
||||
window.__godsEyeView.dataManager.layers.get('cctv').module,
|
||||
media: window.qaCctv.getActiveVideoElement() === window.qaVideo,
|
||||
frames: window.qaVideo.getVideoPlaybackQuality().totalVideoFrames,
|
||||
paused: window.qaVideo.paused,
|
||||
params: window.qaCctv.getParams(),
|
||||
plane: !!window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'fixture-cctv-video').projection
|
||||
?.planeEntity,
|
||||
projection: (() => {
|
||||
const r = window.qaCctv
|
||||
._cctvRecordsForTest()
|
||||
.find((r) => r.camera.id === 'fixture-cctv-video');
|
||||
return {
|
||||
show: r.projection?.planeEntity?.show === true,
|
||||
visible: r.projection?.planeVisible === true,
|
||||
calibration: r.camera.calibration,
|
||||
};
|
||||
})(),
|
||||
}));
|
||||
console.log(
|
||||
kind,
|
||||
v.same,
|
||||
v.media,
|
||||
v.frames,
|
||||
v.plane,
|
||||
v.params.selectedCameraId,
|
||||
v.params.coverageMode,
|
||||
);
|
||||
assert.ok(v.same && v.media && v.plane && !v.paused);
|
||||
assert.equal(v.params.selectedCameraId, 'fixture-cctv-video');
|
||||
assert.equal(v.params.coverageMode, 'viewshed');
|
||||
assert.equal(catalogs, count);
|
||||
await strict.passed(
|
||||
p,
|
||||
'cctv',
|
||||
'same model and video element decoding new frames; textured projection plane; no catalog read',
|
||||
);
|
||||
// The calibration patch (31 m mount, 20° pitch) and the plane's
|
||||
// visibility are kept on the new engine.
|
||||
const { calibration } = v.projection;
|
||||
await strict.check(
|
||||
p,
|
||||
'cctv',
|
||||
'calibration patch kept (31 m, 20° pitch); monitor plane visible',
|
||||
v.projection.show &&
|
||||
v.projection.visible &&
|
||||
calibration?.heightM === 31 &&
|
||||
calibration?.pitchDeg === 20 &&
|
||||
v.params.calibration?.values?.heightM === 31 &&
|
||||
v.params.calibration?.values?.pitchDeg === 20,
|
||||
v.projection,
|
||||
);
|
||||
previous = v.frames;
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained CCTV catalog, decoder and textured projection');
|
||||
});
|
||||
+389
-150
@@ -40,12 +40,10 @@
|
||||
* issuing zero transient samples, without touching the save-gated store;
|
||||
* camera-controller inputs restore and parts hide on mode off.
|
||||
*
|
||||
* SwiftShader caveat (proven in Task 3): under headless software GL, plane
|
||||
* image materials do not visually FILL — screenshotting for a pixel-based
|
||||
* pass/fail is not viable here. All four assertion groups therefore check
|
||||
* entity/counter/localStorage/network STATE (GL-independent), never rendered
|
||||
* pixels. Screenshots are still captured for human visual review but do not
|
||||
* gate the exit code.
|
||||
* This legacy harness is the geometry, lifecycle and real-input oracle.
|
||||
* Migration runs use headless Metal and strict runtime error observation.
|
||||
* Screenshots remain visual-review evidence here; a separate pixel gate must
|
||||
* independently prove actual marker, image-plane and video pixels.
|
||||
*
|
||||
* No `cesium` package import for in-page math: the app does not expose a
|
||||
* `window.Cesium` global, so every in-page evaluate() either (a) reads plain
|
||||
@@ -71,12 +69,17 @@
|
||||
* so the coincide check is deterministic whichever camera activates, but it
|
||||
* is no longer needed to avoid a plane/wireframe disagreement.
|
||||
*
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url http://localhost:4173
|
||||
* Run: node scripts/qa-cctv-v2.mjs --url <explicit-server-url> --out <evidence-directory> [--fixture] [--preview]
|
||||
* Routine keyless builds also pass --terrain-globe: assert zero scene floor
|
||||
* samples instead of claiming mesh-floor coverage on a globe-only map.
|
||||
*
|
||||
* Exit 0 = all asserts passed. Non-zero = a hard failure.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
import { installCctvFixture } from './fixtures/layers/cctv.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -95,12 +98,33 @@ const getOpt = (name, dflt) => {
|
||||
};
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const CCTV_URL = new URL(getOpt('--url', BASE_URL));
|
||||
const explicitUrl = getOpt('--url', process.env.QA_BASE_URL);
|
||||
const output = getOpt('--out');
|
||||
if (!explicitUrl || !output)
|
||||
throw new Error('Explicit --url and --out are required');
|
||||
const mapPolicy = { allowPaidMaps: getFlag('--allow-paid-maps') };
|
||||
const terrainGlobe = getFlag('--terrain-globe');
|
||||
if (terrainGlobe && mapPolicy.allowPaidMaps)
|
||||
throw new Error('Terrain-globe QA must keep paid maps blocked');
|
||||
const CCTV_URL = new URL(explicitUrl);
|
||||
if (
|
||||
['4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
|
||||
CCTV_URL.port,
|
||||
)
|
||||
)
|
||||
throw new Error('CCTV QA may not use a reserved port');
|
||||
if (getFlag('--headful')) throw new Error('CCTV migration QA is headless only');
|
||||
CCTV_URL.searchParams.set('welcome', '0');
|
||||
const APP_URL = CCTV_URL.href;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots', 'cctv-v2');
|
||||
const SHOTS_DIR = path.resolve(output);
|
||||
const evidence = { checks: [], browserErrors: [], pass: false };
|
||||
const saveEvidence = () => {
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(SHOTS_DIR, 'result.json'),
|
||||
JSON.stringify(evidence, null, 2) + '\n',
|
||||
);
|
||||
};
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -127,8 +151,9 @@ function findChromeExecutable() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const results = evidence.checks;
|
||||
function record(name, ok, detail) {
|
||||
detail = redact(detail);
|
||||
results.push({ name, ok, detail });
|
||||
const tag =
|
||||
ok === null
|
||||
@@ -261,34 +286,111 @@ function installRenderFramesHelper(page) {
|
||||
});
|
||||
}
|
||||
|
||||
// In-page helper (stringified and re-defined inside each evaluate that needs
|
||||
// it, since functions can't cross the puppeteer boundary): serializes the
|
||||
// 5 frustum polylines + plane position/orientation/dimensions for a camera
|
||||
// into plain arrays of numbers, using only the camera's own clock time (no
|
||||
// Cesium namespace needed — Property.getValue just needs *a* JulianDate,
|
||||
// and the viewer's own clock.currentTime is a valid live instance of one).
|
||||
const SERIALIZE_GEOM_SRC = `
|
||||
function serializeGeom(camId) {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'];
|
||||
const poly = {};
|
||||
for (const role of roles) {
|
||||
const ent = viewer.entities.getById('cctv-' + camId + '-' + role);
|
||||
poly[role] = ent && ent.polyline
|
||||
? ent.polyline.positions.getValue(time).map((p) => [p.x, p.y, p.z])
|
||||
: null;
|
||||
// In-page helpers (stringified and re-defined inside each evaluate that needs
|
||||
// them, since functions can't cross the puppeteer boundary).
|
||||
//
|
||||
// The frustum wireframe (four corner rays and the far cap) and the gizmo draw
|
||||
// through the globe contract (src/layers/cctv/render/display.js): engine
|
||||
// lines named `cctv-<camera>-<role>` and `cctv-gizmo-<part>`, and the gizmo's
|
||||
// handles in the `cctv-gizmo-points` point set. engine.lines.list() names the
|
||||
// live lines (id, points, shown); their positions are what the layer's own
|
||||
// line handles hold (record.coverageEntities). The monitor plane is a
|
||||
// world mesh, with placement held by the display's plane handle.
|
||||
const CONTRACT_SRC = `
|
||||
function cctvRecord(camId) {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return (
|
||||
mod._cctvRecordsForTest().find((r) => r.camera && r.camera.id === camId) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
function liveLines() {
|
||||
return new Map(
|
||||
window.__godsEyeView.engine.lines.list().map((line) => [line.id, line]),
|
||||
);
|
||||
}
|
||||
// The positions of camera camId's coverage line 'role', when the engine
|
||||
// draws that line with the handle's points; null otherwise.
|
||||
function coverageLinePositions(camId, role, lines = liveLines()) {
|
||||
const id = 'cctv-' + camId + '-' + role;
|
||||
const line = lines.get(id);
|
||||
const record = cctvRecord(camId);
|
||||
const handle = ((record && record.coverageEntities) || []).find(
|
||||
(h) => h && h.id === id,
|
||||
);
|
||||
const positions = handle && handle.polyline && handle.polyline.positions;
|
||||
if (!line || !positions || line.points !== positions.length) return null;
|
||||
return Array.from(positions, (p) => [p.x, p.y, p.z]);
|
||||
}
|
||||
// A Cesium mover set's instance by pickId (the contract's own primitives:
|
||||
// a collection tagged gevMoverSet in scene.primitives).
|
||||
function moverInstance(setId, pickId) {
|
||||
const walk = (collection) => {
|
||||
if (!collection || typeof collection.get !== 'function') return null;
|
||||
if (collection.gevMoverSet === setId) {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (item && item.id === pickId) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const found = walk(collection.get(i));
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(window.__godsEyeView.viewer.scene.primitives);
|
||||
}
|
||||
// 'shown' | 'hidden' | 'missing' for one gizmo part: its line (rings and
|
||||
// arrow shafts) and its point (arrow tips and handles), as many as it has.
|
||||
function gizmoPartState(part) {
|
||||
const id = 'cctv-gizmo-' + part;
|
||||
const line = liveLines().get(id);
|
||||
const point = moverInstance('cctv-gizmo-points', id);
|
||||
if (!line && !point) return 'missing';
|
||||
const shown = [line && line.shown, point && point.show].filter(
|
||||
(value) => value !== undefined && value !== null,
|
||||
);
|
||||
return shown.every(Boolean) ? 'shown' : 'hidden';
|
||||
}
|
||||
// The layer's viewshed volumes whose engine meshes are still live.
|
||||
function viewshedVolumes() {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const live = new Set(window.__godsEyeView.engine.meshes.list());
|
||||
const out = [];
|
||||
for (const record of mod._cctvRecordsForTest()) {
|
||||
const volume = record.viewshedPrimitive;
|
||||
const primitive = volume && !volume.destroyed ? volume.mesh : null;
|
||||
if (primitive && live.has(primitive.id))
|
||||
out.push({ camera: record.camera.id, volume, primitive });
|
||||
}
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
return out;
|
||||
}
|
||||
`;
|
||||
|
||||
// serializeGeom: the 5 frustum polylines + plane position/orientation/
|
||||
// dimensions for a camera as plain arrays of numbers. The plane's properties
|
||||
// are read at the viewer's own clock.currentTime (Property.getValue just needs
|
||||
// *a* JulianDate, and that is a valid live instance of one).
|
||||
const SERIALIZE_GEOM_SRC = `
|
||||
${CONTRACT_SRC}
|
||||
function serializeGeom(camId) {
|
||||
const roles = ['ray-tl', 'ray-tr', 'ray-br', 'ray-bl', 'cap'];
|
||||
const lines = liveLines();
|
||||
const poly = {};
|
||||
for (const role of roles)
|
||||
poly[role] = coverageLinePositions(camId, role, lines);
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
const live = window.__godsEyeView.engine.meshes.list().includes(planeEnt?.id);
|
||||
let plane = null;
|
||||
if (planeEnt) {
|
||||
const pos = planeEnt.position.getValue(time);
|
||||
const orient = planeEnt.orientation.getValue(time);
|
||||
const dims = planeEnt.plane.dimensions.getValue(time);
|
||||
if (live) {
|
||||
const { positions, orientation: orient, shape } = planeEnt.mesh;
|
||||
const pos = positions.capCenter;
|
||||
plane = {
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
orientation: [orient.x, orient.y, orient.z, orient.w],
|
||||
dimensions: [dims.x, dims.y],
|
||||
dimensions: [shape.halfW * 2, shape.halfH * 2],
|
||||
};
|
||||
}
|
||||
return { poly, plane };
|
||||
@@ -425,24 +527,24 @@ function findSafeCalibrationPatch(basePose, groundAltM) {
|
||||
|
||||
async function main() {
|
||||
console.log(`\nCCTV v2 Subsystem Proof`);
|
||||
console.log(` App URL : ${APP_URL}`);
|
||||
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
||||
console.log(` App URL : ${redact(APP_URL)}`);
|
||||
console.log(' Mode : headless Metal\n');
|
||||
|
||||
try {
|
||||
const res = await fetch(APP_URL, { method: 'GET' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||
redact(`Dev server not reachable: ${e.message}`),
|
||||
);
|
||||
process.exit(2);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
}
|
||||
|
||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
|
||||
const chromeExecutable = findChromeExecutable();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: HEADFUL ? false : 'new',
|
||||
headless: true,
|
||||
protocolTimeout: PROTOCOL_TIMEOUT_MS,
|
||||
// Talk CDP over a pipe, so no debugging port stays open on localhost for
|
||||
// the length of a headful run.
|
||||
@@ -451,32 +553,46 @@ async function main() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||
'--use-angle=metal',
|
||||
'--enable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
],
|
||||
});
|
||||
|
||||
const consoleErrors = [];
|
||||
const consoleErrors = evidence.browserErrors;
|
||||
let exitCode = 0;
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page, mapPolicy);
|
||||
const terrainRequests = new Set();
|
||||
let terrainActivityAt = 0;
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname !== '/api/terrain/heights') return;
|
||||
terrainRequests.add(request);
|
||||
terrainActivityAt = Date.now();
|
||||
});
|
||||
for (const event of ['requestfinished', 'requestfailed'])
|
||||
page.on(event, (request) => {
|
||||
if (terrainRequests.delete(request)) terrainActivityAt = Date.now();
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
if (
|
||||
!/Failed to load resource|net::ERR|status of 4\d\d|status of 5\d\d/.test(
|
||||
t,
|
||||
)
|
||||
) {
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
}
|
||||
watchBrowserPage(page, evidence, {
|
||||
name: 'cctv-legacy',
|
||||
baseUrl: APP_URL,
|
||||
preview: getFlag('--preview'),
|
||||
});
|
||||
fixture = getFlag('--fixture')
|
||||
? await installCctvFixture(page)
|
||||
: null;
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
});
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
@@ -492,6 +608,35 @@ async function main() {
|
||||
);
|
||||
if (!appReady) throw new Error('app did not initialise within 60s');
|
||||
await installRenderFramesHelper(page);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.styleManager.initialRestorePromise;
|
||||
app.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of app.dataManager.layers)
|
||||
if (layer.enabled) await app.dataManager.setEnabled(id, false);
|
||||
app.styleManager._setDetectionMode('OFF');
|
||||
});
|
||||
evidence.surface = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
return {
|
||||
map: app.mapStackController.getActiveId(),
|
||||
globeVisible: app.viewer.scene.globe.show,
|
||||
tileset: Boolean(app.tileset),
|
||||
googleKeyPresent: Boolean(window.__GOOGLE_MAPS_API_KEY__),
|
||||
photorealAvailable:
|
||||
app.mapStackController.isStackAvailable('photoreal'),
|
||||
};
|
||||
});
|
||||
evidence.surfaceMode = terrainGlobe ? 'terrain-globe' : 'mesh-floor';
|
||||
if (terrainGlobe) {
|
||||
const keyless =
|
||||
evidence.surface.globeVisible === true &&
|
||||
!evidence.surface.tileset &&
|
||||
!evidence.surface.googleKeyPresent &&
|
||||
!evidence.surface.photorealAvailable;
|
||||
record('keyless terrain-globe startup', keyless);
|
||||
if (!keyless) throw new Error('Expected a keyless globe before CCTV QA');
|
||||
}
|
||||
// Let the initial fly-to Austin and first tiles settle.
|
||||
await sleep(4000);
|
||||
|
||||
@@ -529,7 +674,7 @@ async function main() {
|
||||
console.error(
|
||||
'\x1b[31mCould not wrap scene.pickFromRay/sampleHeight before layer enable — aborting.\x1b[0m',
|
||||
);
|
||||
process.exit(2);
|
||||
throw new Error('CCTV prerequisite unavailable');
|
||||
}
|
||||
|
||||
const readCounters = () =>
|
||||
@@ -557,9 +702,20 @@ async function main() {
|
||||
// ~800ms per real ground sample measured under SwiftShader (each
|
||||
// scene.sampleHeight forces tile loads at the probe point) — real GPU is
|
||||
// far faster, so this is a headless-CI ceiling, not an app expectation.
|
||||
const drainBudgetMs = HEADFUL
|
||||
? Math.max(30000, camCount * 800 + 30000)
|
||||
: Math.min(120000, Math.max(30000, camCount * 800 + 30000));
|
||||
evidence.cameraIds = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState()
|
||||
.cameras.map(({ id }) => id)
|
||||
.sort(),
|
||||
);
|
||||
if (fixture)
|
||||
record(
|
||||
'real catalog parser retains every fixture camera ID',
|
||||
JSON.stringify(evidence.cameraIds) ===
|
||||
JSON.stringify(fixture.cameras.map(({ id }) => id).sort()),
|
||||
);
|
||||
const drainBudgetMs = Math.max(30000, camCount * 800 + 30000);
|
||||
console.log(
|
||||
`Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`,
|
||||
);
|
||||
@@ -574,12 +730,10 @@ async function main() {
|
||||
);
|
||||
record(
|
||||
`geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`,
|
||||
drained ? true : HEADFUL ? false : null,
|
||||
drained,
|
||||
drained
|
||||
? 'loading.active === false'
|
||||
: HEADFUL
|
||||
? 'timed out waiting for drain on the real-GPU sign-off path'
|
||||
: 'headless GL geometry sampling did not settle; current headful evidence is required',
|
||||
: 'timed out waiting for the real-GPU geometry drain',
|
||||
);
|
||||
|
||||
const cAfterDrain = await readCounters();
|
||||
@@ -617,6 +771,7 @@ async function main() {
|
||||
.activeCameraId,
|
||||
);
|
||||
|
||||
evidence.activeCameraId = activeId;
|
||||
const pickDeltaActivation =
|
||||
cAfterActivate.pickFromRay - cBeforeActivate.pickFromRay;
|
||||
record(
|
||||
@@ -660,9 +815,32 @@ async function main() {
|
||||
// (existence, not just ceiling). The wait below tolerates the update-tick
|
||||
// latency of the completion pass (update interval is 10s).
|
||||
// -----------------------------------------------------------------------
|
||||
const globeMode = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.scene.globe?.show !== false,
|
||||
console.log('Waiting for tiles + the one-shot completion pass...');
|
||||
const tilesSeenLoaded = await waitForTilesLoaded(page, 45000);
|
||||
let sampleFloorOk = false;
|
||||
if (tilesSeenLoaded && !terrainGlobe) {
|
||||
sampleFloorOk = await page
|
||||
.waitForFunction(
|
||||
(base) => window.__qaCounters.sampleHeight - base >= 1,
|
||||
{ timeout: 30000 },
|
||||
c0.sampleHeight,
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
const cFloor = await readCounters();
|
||||
record(
|
||||
terrainGlobe
|
||||
? 'terrain-globe resolves from priors with zero scene ground samples'
|
||||
: '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)',
|
||||
terrainGlobe
|
||||
? cFloor.sampleHeight - c0.sampleHeight === 0
|
||||
: tilesSeenLoaded && sampleFloorOk,
|
||||
tilesSeenLoaded
|
||||
? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}`
|
||||
: 'tilesLoaded never observed within 45s — floor not assessable this run',
|
||||
);
|
||||
const globeMode = await page.evaluate(() => window.__godsEyeView.viewer.scene.globe?.show !== false);
|
||||
if (globeMode) {
|
||||
// Globe stacks intentionally perform no scene.sampleHeight query.
|
||||
// Require independently fetched DEM heights and the actual rendered
|
||||
@@ -691,14 +869,18 @@ async function main() {
|
||||
(await waitForPredicate(
|
||||
page,
|
||||
(expected, base) => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const camera = dataManager.layers
|
||||
.get('cctv')
|
||||
.module.getUIState().activeCamera;
|
||||
const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`);
|
||||
const mount = ray?.polyline?.positions.getValue(
|
||||
viewer.clock.currentTime,
|
||||
)?.[0];
|
||||
const { viewer, dataManager, engine } = window.__godsEyeView;
|
||||
const module = dataManager.layers.get('cctv').module;
|
||||
const camera = module.getUIState().activeCamera;
|
||||
// The corner ray is an engine line; its first point (the mount)
|
||||
// is what the layer's line handle gave it.
|
||||
const id = `cctv-${camera.id}-ray-tl`;
|
||||
const drawn = engine.lines.list().some((line) => line.id === id);
|
||||
const ray = module
|
||||
._cctvRecordsForTest()
|
||||
.find((record) => record.camera?.id === camera.id)
|
||||
?.coverageEntities?.find((handle) => handle?.id === id);
|
||||
const mount = drawn ? ray?.polyline?.positions?.[0] : undefined;
|
||||
const height =
|
||||
mount &&
|
||||
viewer.scene.globe.ellipsoid.cartesianToCartographic(mount)
|
||||
@@ -849,7 +1031,7 @@ async function main() {
|
||||
// source. Assert the migrated surface via the host diagnostics.
|
||||
const diag = window.__gevWorldOverlay?.getDiagnostics?.();
|
||||
g.hasLabel = (diag?.entriesBySource?.['cctv-projection'] || 0) >= 1;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const planeEnt = cctvRecord(camId)?.projection?.planeEntity;
|
||||
g.planeShow = planeEnt ? planeEnt.show : null;
|
||||
return g;
|
||||
})(${JSON.stringify(activeId)})`);
|
||||
@@ -862,14 +1044,14 @@ async function main() {
|
||||
'cap',
|
||||
].every((r) => Array.isArray(geomInfo.poly[r]));
|
||||
record(
|
||||
'all 5 frustum polyline entities exist for the active camera',
|
||||
'all 5 frustum lines are drawn for the active camera (engine lines)',
|
||||
allPolylinesPresent,
|
||||
Object.keys(geomInfo.poly)
|
||||
.map((k) => `${k}=${geomInfo.poly[k] ? 'ok' : 'MISSING'}`)
|
||||
.join(', '),
|
||||
);
|
||||
record(
|
||||
'plane + plane-label entities exist for the active camera',
|
||||
'plane mesh and label exist for the active camera',
|
||||
!!geomInfo.plane && geomInfo.hasLabel,
|
||||
`plane=${!!geomInfo.plane} label=${geomInfo.hasLabel} show=${geomInfo.planeShow}`,
|
||||
);
|
||||
@@ -1034,8 +1216,27 @@ async function main() {
|
||||
return serializeGeom(camId);
|
||||
})(${JSON.stringify(camId)})`);
|
||||
const geomBefore30s = await serializeGeom(activeId);
|
||||
await sleep(30000);
|
||||
await sleep(28000);
|
||||
evidence.staticIdle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
|
||||
let frames = 0;
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => frames++);
|
||||
const started = performance.now();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { frames, durationMs: performance.now() - started,
|
||||
governor: getRenderGovernorDiagnostics(app.engine) };
|
||||
});
|
||||
const geomAfter30s = await serializeGeom(activeId);
|
||||
evidence.geometry = {
|
||||
initial: geomInfo,
|
||||
beforeIdle: geomBefore30s,
|
||||
afterIdle: geomAfter30s,
|
||||
};
|
||||
evidence.countersAfterIdle = await readCounters();
|
||||
record('static image projection settles to zero frames over 2s',
|
||||
evidence.staticIdle.frames === 0, JSON.stringify(evidence.staticIdle));
|
||||
const geomStable =
|
||||
JSON.stringify(geomBefore30s) === JSON.stringify(geomAfter30s);
|
||||
record(
|
||||
@@ -1207,7 +1408,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Group 4: frame-loop health (STATE, not pixels — SwiftShader caveat)
|
||||
// Group 4: frame-loop health (state oracle; media pixels have a separate gate)
|
||||
// =========================================================================
|
||||
console.log(
|
||||
'Checking frame-loop health (placeholder -> frame swap, URL family)...',
|
||||
@@ -1218,15 +1419,11 @@ async function main() {
|
||||
// never null/undefined once the runtime exists, i.e. there's no gap where
|
||||
// the plane shows nothing.
|
||||
const materialInfo = await page.evaluate((camId) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const planeEnt = viewer.entities.getById('cctv-' + camId + '-plane');
|
||||
const mat = planeEnt?.plane?.material;
|
||||
const image = mat?.image;
|
||||
const resolved =
|
||||
image && typeof image.getValue === 'function'
|
||||
? image.getValue(time)
|
||||
: image;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const runtime = mod._cctvRecordsForTest().find((r) => r.camera.id === camId)?.projection;
|
||||
const planeEnt = runtime?.planeEntity;
|
||||
const mat = runtime?.planeMaterial;
|
||||
const resolved = mat?.image;
|
||||
return {
|
||||
hasMaterial: !!mat,
|
||||
hasImage: !!resolved,
|
||||
@@ -1288,8 +1485,9 @@ async function main() {
|
||||
const scene = viewer.scene;
|
||||
const canvas = scene.canvas;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const { hitTestWorldOverlay } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const { hitTest } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const candidates = [
|
||||
[0.55, 0.72],
|
||||
[0.45, 0.72],
|
||||
@@ -1311,7 +1509,7 @@ async function main() {
|
||||
const x = Math.round(canvas.clientWidth * fx);
|
||||
const y = Math.round(canvas.clientHeight * fy);
|
||||
const picked = scene.pick({ x, y });
|
||||
const card = hitTestWorldOverlay(x, y, { sourceId: 'cctv' });
|
||||
const card = hitTest(x, y, { sourceId: 'cctv' });
|
||||
const top = document.elementFromPoint(rect.left + x, rect.top + y);
|
||||
if (
|
||||
resolveId(picked) === null &&
|
||||
@@ -1464,25 +1662,18 @@ async function main() {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const scene = viewer.scene;
|
||||
const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const canvasPoint = new Cesium.Cartesian2(
|
||||
emptyPoint.canvasX,
|
||||
emptyPoint.canvasY,
|
||||
);
|
||||
const canvasPoint = { x: emptyPoint.canvasX, y: emptyPoint.canvasY };
|
||||
const ray = viewer.camera.getPickRay(canvasPoint);
|
||||
if (!ray) return null;
|
||||
let position = null;
|
||||
let projected = null;
|
||||
for (const distance of [1_000, 10_000, 100_000, 1_000_000]) {
|
||||
const candidate = Cesium.Ray.getPoint(
|
||||
const candidate = ray.constructor.getPoint(
|
||||
ray,
|
||||
distance,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
candidate,
|
||||
new ray.origin.constructor(),
|
||||
);
|
||||
const screen = scene.cartesianToCanvasCoordinates(candidate);
|
||||
if (
|
||||
screen &&
|
||||
Math.hypot(screen.x - canvasPoint.x, screen.y - canvasPoint.y) < 2
|
||||
@@ -1498,7 +1689,7 @@ async function main() {
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 64,
|
||||
color: Cesium.Color.MAGENTA,
|
||||
color: new scene.backgroundColor.constructor(1, 0, 1, 1),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
@@ -1537,7 +1728,7 @@ async function main() {
|
||||
!siblingTarget
|
||||
? null
|
||||
: siblingOwnsPick ||
|
||||
(siblingRendererStalled && !HEADFUL ? null : false),
|
||||
(siblingRendererStalled ? null : false),
|
||||
siblingTarget
|
||||
? `${siblingRendererStalled ? 'renderer stalled: ' : ''}frames=${siblingTarget.framesRendered}/3 picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}`
|
||||
: 'no previously verified empty canvas ray was available',
|
||||
@@ -1664,17 +1855,40 @@ async function main() {
|
||||
console.log(
|
||||
'Checking viewshed mode (coverage tri-state + color-coded volumes)...',
|
||||
);
|
||||
// Every layer volume owns one engine mesh.
|
||||
const countVolumes = () =>
|
||||
page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
let n = 0;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) n += 1;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
return viewshedVolumes().length;
|
||||
})()`);
|
||||
|
||||
const cBeforeViewshed = await readCounters();
|
||||
await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const meshes = window.__godsEyeView.engine.meshes;
|
||||
const events = [];
|
||||
window.__qaViewshedTrace = events;
|
||||
const create = meshes.createMesh.bind(meshes);
|
||||
meshes.createMesh = (options) => {
|
||||
const primitive = create(options);
|
||||
if (options.id.startsWith('cctv-volume-')) {
|
||||
const record = (method) => events.push({ method, primitive, at: performance.now(), stack: new Error().stack });
|
||||
record('add');
|
||||
const destroy = primitive.destroy.bind(primitive);
|
||||
primitive.destroy = () => { record('remove'); destroy(); };
|
||||
}
|
||||
return primitive;
|
||||
};
|
||||
window.__qaViewshedTraceRead = () => {
|
||||
const owners = new Map(
|
||||
viewshedVolumes().map((entry) => [entry.primitive, entry.camera]),
|
||||
);
|
||||
return events.map(({ primitive, ...event }) => ({
|
||||
...event,
|
||||
camera: owners.get(primitive) ?? null,
|
||||
}));
|
||||
};
|
||||
})()`);
|
||||
const modeAfterSet = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
@@ -1725,27 +1939,35 @@ async function main() {
|
||||
mod.setParams({ coverageMode: 'viewshed' });
|
||||
});
|
||||
await sleep(400);
|
||||
await page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
window.__qaViewshedRefs = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed)
|
||||
window.__qaViewshedRefs.push(prims.get(i));
|
||||
}
|
||||
});
|
||||
// NEXT starts an asynchronous footprint-height lookup. Its completion is
|
||||
// allowed to update geometry; the unchanged 8s idle window starts after it.
|
||||
const terrainDeadline = Date.now() + 35000;
|
||||
while (
|
||||
(terrainRequests.size || Date.now() - terrainActivityAt < 250) &&
|
||||
Date.now() < terrainDeadline
|
||||
)
|
||||
await sleep(50);
|
||||
const terrainSettled =
|
||||
terrainRequests.size === 0 && Date.now() - terrainActivityAt >= 250;
|
||||
record('footprint requests settle before viewshed idle', terrainSettled);
|
||||
if (!terrainSettled) throw new Error('Footprint requests did not settle');
|
||||
await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
window.__qaViewshedRefs = viewshedVolumes().map((entry) => entry.primitive);
|
||||
})()`);
|
||||
await sleep(8000);
|
||||
const viewshedIdle = await page.evaluate(() => {
|
||||
const prims = window.__godsEyeView.viewer.scene.primitives;
|
||||
const now = [];
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
if (prims.get(i) && prims.get(i)._gevViewshed) now.push(prims.get(i));
|
||||
}
|
||||
const viewshedIdle = await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const now = viewshedVolumes().map((entry) => entry.primitive);
|
||||
const before = window.__qaViewshedRefs || [];
|
||||
const sameCount = now.length === before.length;
|
||||
const sameIdentity = sameCount && before.every((p) => now.includes(p));
|
||||
return { sameCount, sameIdentity, count: now.length };
|
||||
});
|
||||
})()`);
|
||||
const cAfterViewshed = await readCounters();
|
||||
evidence.viewshedTrace = await page.evaluate(() =>
|
||||
window.__qaViewshedTraceRead?.(),
|
||||
);
|
||||
const lateFloorSamples =
|
||||
cAfterViewshed.sampleHeight - cBeforeViewshed.sampleHeight;
|
||||
record(
|
||||
@@ -1772,6 +1994,11 @@ async function main() {
|
||||
// Group 7: calibration gizmo (v3 design §3c — ADJUST mode + drag)
|
||||
// =========================================================================
|
||||
console.log('Checking calibration gizmo (ADJUST mode + synthetic drag)...');
|
||||
// NEXT above selects a different catalog camera without moving the view.
|
||||
// Frame that actual camera before requiring an on-screen physical drag.
|
||||
await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv')
|
||||
.module.setParams({ focusSelected: true, focusDurationSec: 0.2 }));
|
||||
await sleep(800);
|
||||
const GIZMO_PARTS = [
|
||||
'ring-heading',
|
||||
'ring-pitch',
|
||||
@@ -1782,15 +2009,12 @@ async function main() {
|
||||
'handle-fov-l',
|
||||
'handle-fov-r',
|
||||
];
|
||||
const gizmoStates = await page.evaluate((parts) => {
|
||||
const gizmoStates = await page.evaluate(`(function(parts){
|
||||
${CONTRACT_SRC}
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ calibrationMode: true });
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
return parts.map((p) => {
|
||||
const e = viewer.entities.getById('cctv-gizmo-' + p);
|
||||
return e ? (e.show ? 'shown' : 'hidden') : 'missing';
|
||||
});
|
||||
}, GIZMO_PARTS);
|
||||
return parts.map(gizmoPartState);
|
||||
})(${JSON.stringify(GIZMO_PARTS)})`);
|
||||
record(
|
||||
'all 8 gizmo parts exist and show in ADJUST mode',
|
||||
gizmoStates.every((s) => s === 'shown'),
|
||||
@@ -1800,11 +2024,20 @@ async function main() {
|
||||
// Synthetic CDP drag on the EAST arrow — the owner's field-test
|
||||
// regression. Sample effective elevation after every mouse move: it must
|
||||
// stay frozen until release, with no transient sampleHeight calls.
|
||||
const eastDrag = await page.evaluate(() => {
|
||||
// The E arrow's shaft runs from the active camera's mount to its tip,
|
||||
// the `cctv-gizmo-move-east` point in the gizmo's point set.
|
||||
const eastDrag = await page.evaluate(`(function(){
|
||||
${CONTRACT_SRC}
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const e = viewer.entities.getById('cctv-gizmo-move-east');
|
||||
if (!e) return null;
|
||||
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const active = mod.getUIState().activeCameraId;
|
||||
const mount = cctvRecord(active)?.frustumPositions?.mount;
|
||||
const tip = moverInstance('cctv-gizmo-points', 'cctv-gizmo-move-east');
|
||||
if (!mount || !tip || gizmoPartState('move-east') !== 'shown') return null;
|
||||
const pts = [
|
||||
new tip.position.constructor(mount.x, mount.y, mount.z),
|
||||
tip.position,
|
||||
];
|
||||
const scene = viewer.scene;
|
||||
const w = scene.canvas.clientWidth;
|
||||
const h = scene.canvas.clientHeight;
|
||||
@@ -1833,7 +2066,7 @@ async function main() {
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
return { ...candidates[0], candidates, ux: dx / mag, uy: dy / mag };
|
||||
});
|
||||
})()`);
|
||||
|
||||
const calBeforeDrag = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
@@ -2020,7 +2253,7 @@ async function main() {
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
`${missDetail} — verify on a current headful GPU surface`,
|
||||
`${missDetail} — required physical drag evidence is missing from this headless Metal run`,
|
||||
);
|
||||
record(
|
||||
'east-arrow drag keeps mount elevation frozen until mouse-up',
|
||||
@@ -2044,7 +2277,7 @@ async function main() {
|
||||
);
|
||||
} else {
|
||||
const unavailable =
|
||||
'no on-screen east-arrow shaft found under this GL stack — verify on real GPU';
|
||||
'no on-screen east-arrow shaft found in the headless Metal view';
|
||||
record(
|
||||
'east-arrow drag changes the east offset (live, unsaved)',
|
||||
null,
|
||||
@@ -2078,16 +2311,13 @@ async function main() {
|
||||
);
|
||||
|
||||
// ADJUST off: parts hidden; leave a clean calibration for the next group.
|
||||
const gizmoOff = await page.evaluate((parts) => {
|
||||
const gizmoOff = await page.evaluate(`(function(parts){
|
||||
${CONTRACT_SRC}
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
mod.setParams({ calibrationMode: false });
|
||||
mod.setParams({ calibration: { reset: true } });
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
return parts.map((p) => {
|
||||
const e = viewer.entities.getById('cctv-gizmo-' + p);
|
||||
return e ? (e.show ? 'shown' : 'hidden') : 'missing';
|
||||
});
|
||||
}, GIZMO_PARTS);
|
||||
return parts.map(gizmoPartState);
|
||||
})(${JSON.stringify(GIZMO_PARTS)})`);
|
||||
record(
|
||||
'gizmo parts hide when ADJUST mode turns off',
|
||||
gizmoOff.every((s) => s === 'hidden'),
|
||||
@@ -2095,8 +2325,8 @@ async function main() {
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshots for human visual review (NOT a pass/fail signal — SwiftShader
|
||||
// cannot fill plane image materials under headless software GL).
|
||||
// Screenshots supplement this state/geometry oracle. Positive rendered
|
||||
// media pixels are a separate mandatory pixel gate.
|
||||
// -----------------------------------------------------------------------
|
||||
await sleep(1000);
|
||||
const shot = path.join(SHOTS_DIR, 'cctv-v2-active.png');
|
||||
@@ -2110,10 +2340,16 @@ async function main() {
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r.ok === false) exitCode = 1;
|
||||
if (r.ok !== true) exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await fixture?.dispose();
|
||||
await browser.close();
|
||||
evidence.pass =
|
||||
results.length > 0 &&
|
||||
results.every((r) => r.ok === true) &&
|
||||
!consoleErrors.length;
|
||||
saveEvidence();
|
||||
}
|
||||
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
@@ -2128,6 +2364,9 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
evidence.pass = false;
|
||||
evidence.error = redact(e?.stack || e);
|
||||
saveEvidence();
|
||||
console.error('Harness error:', evidence.error);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { blockPaidQaMaps } from './qa-map-policy.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const shotsDir = path.join(repoRoot, 'qa-shots', 'cockpit-utility');
|
||||
@@ -29,6 +30,7 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await blockPaidQaMaps(page);
|
||||
const failures = [];
|
||||
const consoleErrors = [];
|
||||
const localHttpErrors = [];
|
||||
@@ -827,6 +829,7 @@ try {
|
||||
const readinessPage = await browser.newPage();
|
||||
let deferredInstallationReadiness;
|
||||
try {
|
||||
await blockPaidQaMaps(readinessPage);
|
||||
await readinessPage.setViewport({ width: 1440, height: 900 });
|
||||
readinessPage.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||
await readinessPage.setRequestInterception(true);
|
||||
@@ -1150,6 +1153,14 @@ try {
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const startsClear = await page.evaluate(() => {
|
||||
const hidden = ['left-panel-stack', 'intel-hud', 'corner-actions',
|
||||
'cockpit-context', 'cockpit-signal-stream', 'cockpit-utility-controls'];
|
||||
return hidden.every((id) => document.getElementById(id)?.getClientRects().length === 0)
|
||||
&& document.getElementById('cockpit-speed-value').getClientRects().length > 0;
|
||||
});
|
||||
check('Cockpit starts with flight instruments and hides the map panels', startsClear);
|
||||
await page.click('#cockpit-panels-toggle');
|
||||
const firstCockpitContact = await page.evaluate(() => {
|
||||
const awareness = window.__godsEyeView.dataManager.layers
|
||||
.get('military-awareness')?.module;
|
||||
@@ -1511,6 +1522,9 @@ try {
|
||||
const before = read();
|
||||
document.getElementById('cockpit-entry')?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const startsClear = document.getElementById('cockpit-panels-toggle').getAttribute('aria-pressed') === 'false';
|
||||
document.getElementById('cockpit-panels-toggle').click();
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const during = read();
|
||||
manager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
@@ -1531,6 +1545,7 @@ try {
|
||||
before,
|
||||
during,
|
||||
after: read(),
|
||||
startsClear,
|
||||
exited,
|
||||
clearancePx,
|
||||
contactAutoCollapsed,
|
||||
@@ -1540,7 +1555,7 @@ try {
|
||||
});
|
||||
check(
|
||||
'Cockpit temporarily collapses standard panels and restores the exact map layout on exit',
|
||||
cockpitPanelRoundTrip.exited
|
||||
cockpitPanelRoundTrip.exited && cockpitPanelRoundTrip.startsClear
|
||||
&& Object.values(cockpitPanelRoundTrip.during).every(Boolean)
|
||||
&& JSON.stringify(cockpitPanelRoundTrip.after)
|
||||
=== JSON.stringify(cockpitPanelRoundTrip.before)
|
||||
@@ -1605,6 +1620,7 @@ try {
|
||||
const states = [read()];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
current.click();
|
||||
await Promise.resolve();
|
||||
states.push(read());
|
||||
}
|
||||
const originalStyle = manager.activeStyle;
|
||||
@@ -1616,8 +1632,10 @@ try {
|
||||
const enteredOnNoir = await reenter();
|
||||
const entryNoir = read();
|
||||
cockpit.setVisionMode('optical');
|
||||
await Promise.resolve();
|
||||
const normalInsideNoir = read();
|
||||
cockpit.setVisionMode('crt');
|
||||
await Promise.resolve();
|
||||
const cockpitOnlyCrt = read();
|
||||
const exitedNoir = cockpit.exit({ restoreTracking: true }) === true;
|
||||
await settle();
|
||||
@@ -1692,6 +1710,9 @@ try {
|
||||
await page.screenshot({ path: path.join(shotsDir, 'vision-noir.png') });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.cockpitView.setVisionMode('optical'));
|
||||
|
||||
await page.$eval('#cockpit-panels-toggle', button => {
|
||||
if (button.getAttribute('aria-pressed') === 'false') button.click();
|
||||
});
|
||||
const desktopState = async (variant, openKind) => page.evaluate(async ({ variantName, kind }) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const hud = document.getElementById('cockpit-hud');
|
||||
@@ -1877,7 +1898,7 @@ try {
|
||||
// the wall Cockpit actually solves against — not by injecting an anchor.
|
||||
// The card is bottom-anchored, so an explicit height moves its top edge.
|
||||
const minTop = Math.max(96, window.innerHeight * 0.12);
|
||||
const moveSignalTopTo = (wantTop) => {
|
||||
const moveSignalTopTo = async (wantTop) => {
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const rect = signal.getBoundingClientRect();
|
||||
const delta = wantTop - rect.top;
|
||||
@@ -1890,9 +1911,11 @@ try {
|
||||
// the now-shorter strip, so idempotence is asserted where the strip's
|
||||
// composition is unchanged.)
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const firstPassTop = hud.style.getPropertyValue('--cockpit-utility-top');
|
||||
const firstPassPrimaryOnly = utility.classList.contains('layout-primary-only');
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
return {
|
||||
firstPassTop,
|
||||
firstPassPrimaryOnly,
|
||||
@@ -1903,6 +1926,7 @@ try {
|
||||
signal.style.maxHeight = 'none';
|
||||
signal.style.height = '150px';
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
await Promise.resolve();
|
||||
const expandedHeight = Math.max(displayControl.scrollHeight, displayControl.getBoundingClientRect().height);
|
||||
const collapsedHeight = Math.max(50, radioControl.scrollHeight);
|
||||
const stripHeight = expandedHeight + collapsedHeight + 7;
|
||||
@@ -1927,11 +1951,11 @@ try {
|
||||
// because 150 px of card leaves a corridor sized for the OLD strip. Seed from
|
||||
// the measured requirement instead, which holds for any future panel height,
|
||||
// and assert the precondition rather than assuming it.
|
||||
const roomySeed = moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeed = await moveSignalTopTo(exactSignalTop + 80);
|
||||
const roomySeedExpanded = !utility.classList.contains('layout-primary-only')
|
||||
&& roomySeed.firstPassPrimaryOnly === false;
|
||||
const exact = {
|
||||
...moveSignalTopTo(exactSignalTop),
|
||||
...await moveSignalTopTo(exactSignalTop),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
@@ -1941,7 +1965,7 @@ try {
|
||||
const constrained = {
|
||||
// One pixel on the far side of the same fit (`exactSignalTop` already sits
|
||||
// one pixel inside it), so this is still the one-pixel constraint it claims.
|
||||
...moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
...await moveSignalTopTo(exactSignalTop - 2 * BOUNDARY_EPSILON_PX),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingDisplay: getComputedStyle(radioControl).display,
|
||||
@@ -1952,7 +1976,7 @@ try {
|
||||
<= signal.getBoundingClientRect().top - 7,
|
||||
};
|
||||
const restored = {
|
||||
...moveSignalTopTo(exactSignalTop + 80),
|
||||
...await moveSignalTopTo(exactSignalTop + 80),
|
||||
utilityTop: hud.style.getPropertyValue('--cockpit-utility-top'),
|
||||
primaryOnly: utility.classList.contains('layout-primary-only'),
|
||||
siblingVisible: getComputedStyle(radioControl).display !== 'none',
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['military-awareness', 'military-installations'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/vessels')
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Toronto ' + i,
|
||||
lat: 43.65,
|
||||
lon: -79.38 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
// Contacts reads air quality near the subject; no station here.
|
||||
if (
|
||||
new URL(input.url ?? input, document.baseURI).hostname ===
|
||||
'api.weather.gc.ca'
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({ type: 'FeatureCollection', features: [] }),
|
||||
);
|
||||
if (pathname === '/api/vessels/track')
|
||||
return Promise.resolve(Response.json({ samples: [] }));
|
||||
if (pathname === '/api/flights')
|
||||
return Promise.resolve(
|
||||
Response.json({ time: Date.now() / 1000, states: [] }),
|
||||
);
|
||||
if (pathname === '/api/military')
|
||||
return Promise.resolve(Response.json({ now: Date.now(), ac: [] }));
|
||||
// One mapped station next to the Contacts subject, with a footprint.
|
||||
if (pathname === '/api/military-installations') {
|
||||
window.qaInstallationReads = (window.qaInstallationReads ?? 0) + 1;
|
||||
const ring = [
|
||||
[43.665, -79.345],
|
||||
[43.665, -79.315],
|
||||
[43.68, -79.315],
|
||||
[43.68, -79.345],
|
||||
[43.665, -79.345],
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'ok',
|
||||
elements: [
|
||||
{
|
||||
type: 'way',
|
||||
id: 424242,
|
||||
tags: { military: 'naval_base', name: 'QA Harbour Station' },
|
||||
center: { lat: 43.6725, lon: -79.33 },
|
||||
geometry: ring.map(([lat, lon]) => ({ lat, lon })),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetch(input, options);
|
||||
};
|
||||
window.qaSelected = [];
|
||||
for (const type of ['gev:entity-selected', 'gev:entity-selection-cleared'])
|
||||
window.addEventListener(type, ({ detail }) =>
|
||||
window.qaSelected.push({
|
||||
type,
|
||||
id: detail?.id,
|
||||
layerId: detail?.layerId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaBefore = [...dataManager.getEnabledLayerIds()].sort();
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaContext = dataManager.layers.get('military-awareness').module;
|
||||
window.qaSites = dataManager.layers.get('military-installations').module;
|
||||
const vessels = dataManager.layers.get('ais-live-vessels').module;
|
||||
if (!vessels.selectById('123456789')) throw Error('Missing first vessel');
|
||||
if (!window.qaContext.navigateNext({ targetLayer: 'ais-live-vessels' }))
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const sample = () =>
|
||||
p.evaluate(() => {
|
||||
const { dataManager, styleManager, engine } = window.__godsEyeView;
|
||||
return {
|
||||
context:
|
||||
window.qaContext ===
|
||||
dataManager.layers.get('military-awareness').module,
|
||||
sites:
|
||||
window.qaSites ===
|
||||
dataManager.layers.get('military-installations').module,
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
history:
|
||||
window.qaContext._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
ring:
|
||||
engine.kind === 'navara'
|
||||
? engine.lines
|
||||
.list()
|
||||
.filter((line) => line.id === 'gev-awareness-rim').length
|
||||
: null,
|
||||
};
|
||||
});
|
||||
// The station zone: the fixture's installation is the one mapped site
|
||||
// within the Contacts radius of the subject, and the engine draws it where
|
||||
// a pointer can reach it (the engine's own picker returns it at an
|
||||
// uncovered canvas point at its marker or inside its footprint).
|
||||
const inside = [fromDegrees(-79.33, 43.6725)];
|
||||
for (let i = 1; i < 6; i++)
|
||||
for (let k = 1; k < 6; k++)
|
||||
inside.push(fromDegrees(-79.345 + 0.005 * k, 43.665 + 0.0025 * i));
|
||||
const zone = async () => {
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
!window.qaSites.getStats().loading &&
|
||||
window.qaSites
|
||||
.getAnalystRecords()
|
||||
.some((r) => r.name === 'QA Harbour Station'),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const drawn = await p
|
||||
.waitForFunction(
|
||||
(points) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
for (const world of points) {
|
||||
const centre = engine.projector.worldToWindow(world, {});
|
||||
if (!centre) continue;
|
||||
for (const [dx, dy] of [
|
||||
[0, 0],
|
||||
[0, -8],
|
||||
[8, 0],
|
||||
[-8, 0],
|
||||
[0, 8],
|
||||
]) {
|
||||
const at = { x: centre.x + dx, y: centre.y + dy };
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + at.x, box.top + at.y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const id = engine.picker.pick(at)?.id;
|
||||
if (String(id?.id ?? id) === 'osm:way:424242')
|
||||
return [Math.round(at.x), Math.round(at.y)];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
inside,
|
||||
)
|
||||
.then((handle) => handle.jsonValue())
|
||||
.catch(() => null);
|
||||
return p.evaluate(
|
||||
(drawn) => ({
|
||||
drawn,
|
||||
records: window.qaSites.getAnalystRecords().map((r) => r.name),
|
||||
coverage: window.qaSites.getStats().coverage,
|
||||
count: window.qaSites.getStats().count,
|
||||
reads: window.qaInstallationReads,
|
||||
subject: window.qaContext.getContextSnapshot()?.subject?.id,
|
||||
}),
|
||||
drawn,
|
||||
);
|
||||
};
|
||||
const checkZone = async () => {
|
||||
const station = await zone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the station zone holds the fixture station, drawn and pickable',
|
||||
station.count === 1 &&
|
||||
station.records.length === 1 &&
|
||||
station.records[0] === 'QA Harbour Station' &&
|
||||
station.coverage?.kind === 'subject' &&
|
||||
Array.isArray(station.drawn),
|
||||
station,
|
||||
);
|
||||
return station;
|
||||
};
|
||||
const firstZone = await checkZone();
|
||||
const baseline = await sample();
|
||||
console.log('baseline', JSON.stringify(baseline));
|
||||
assert.equal(baseline.mode, 'flights');
|
||||
assert.equal(baseline.subject, '223456789');
|
||||
assert.ok(baseline.history.includes('ais-live-vessels:123456789'));
|
||||
assert.ok(baseline.ring >= 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'Contacts subject, navigation history and awareness rim',
|
||||
baseline,
|
||||
);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const current = await sample();
|
||||
console.log('handoff', kind, JSON.stringify(current));
|
||||
assert.deepEqual(current, {
|
||||
...baseline,
|
||||
ring: kind === 'navara' ? baseline.ring : null,
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-awareness',
|
||||
'same awareness model, Contacts mode, subject, history and enabled layers',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'military-installations',
|
||||
'same installations model and enabled through the switch',
|
||||
);
|
||||
const station = await checkZone();
|
||||
await strict.check(
|
||||
p,
|
||||
'military-installations',
|
||||
'the switch reads no installation snapshot',
|
||||
station.reads === firstZone.reads,
|
||||
{ before: firstZone.reads, after: station.reads },
|
||||
);
|
||||
}
|
||||
await checkZone();
|
||||
const restored = await p.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
return (
|
||||
result.ok &&
|
||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
||||
JSON.stringify(window.qaBefore)
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
restored,
|
||||
'Restore layers enabled by the user before entering Contacts',
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Contacts history, dependency ownership and exit restore across both globes',
|
||||
);
|
||||
});
|
||||
@@ -338,10 +338,16 @@ async function contacts() {
|
||||
candidate.querySelector('strong')?.textContent?.trim() ===
|
||||
'Mapped installations',
|
||||
);
|
||||
const ds = window.__godsEyeView.viewer.dataSources.getByName(
|
||||
'military-installations',
|
||||
)[0];
|
||||
for (const entity of ds?.entities.values || [])
|
||||
// The drawn sites are context handles in the shared context store
|
||||
// (src/layers/installations/render/display.js keeps a site's handle
|
||||
// while its geometry is unchanged), not Entities: a new handle is a
|
||||
// rebuilt site.
|
||||
const sites = [
|
||||
...(window.__gevContextStore?.entities.values() || []),
|
||||
].filter((record) =>
|
||||
String(record.entity?.gevTrackedId ?? '').startsWith('installations:'),
|
||||
);
|
||||
for (const { entity } of sites)
|
||||
if (!seen.has(entity)) {
|
||||
seen.add(entity);
|
||||
created += 1;
|
||||
@@ -357,7 +363,7 @@ async function contacts() {
|
||||
name: button.childNodes[0]?.textContent?.trim(),
|
||||
distance: button.querySelector('span')?.textContent?.trim(),
|
||||
})),
|
||||
entities: ds?.entities.values.length ?? 0,
|
||||
entities: sites.length,
|
||||
created,
|
||||
tracked:
|
||||
window.__godsEyeView.viewer.trackedEntity?.gevTrackedId ?? null,
|
||||
@@ -429,7 +435,7 @@ async function contacts() {
|
||||
`${reverted.length}/${flight.length}`,
|
||||
);
|
||||
check(
|
||||
'site entities are not rebuilt while tracking',
|
||||
'drawn sites are not rebuilt while tracking',
|
||||
createdDuring === 0,
|
||||
`${createdDuring} created`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
|
||||
* headless Chrome against the real engines of a running app, the way
|
||||
* contract.test.mjs runs it against the fake engine.
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
|
||||
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
|
||||
*
|
||||
* Serve the app keyless from the dev server: Google and Cesium ion requests
|
||||
* are refused here anyway, so neither engine draws photoreal tiles. The page
|
||||
* imports the suite from the dev server, so it shares the app's modules (an
|
||||
* engine event is recognised by the module that made it). It gets a real
|
||||
* pointer (the pick checks), real 2D canvases for sprite atlases, and no map
|
||||
* sources (the maps checks would ask for photoreal tiles). Exits 1 when any check fails on any engine.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const BLOCKED_HOSTS =
|
||||
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const origin = args.find((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
if (!origin) {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
// --only=prefix,prefix runs just the checks whose names start with one.
|
||||
const only = option('only')?.split(',') ?? null;
|
||||
|
||||
// Checks of state the running app drives itself, by name prefix, with the
|
||||
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
|
||||
// so a value the suite sets does not survive to its next frame.
|
||||
const APP_OWNED = Object.freeze([
|
||||
['render.setContactSonar', 'src/cyberSonarEngine.js sets it every frame'],
|
||||
]);
|
||||
|
||||
async function runOn(browser, kind) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
const { hostname } = new URL(request.url());
|
||||
if (BLOCKED_HOSTS.test(hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
// A view in the link: the app starts there instead of flying to a city.
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(expected) =>
|
||||
window.__godsEyeView?.engine?.kind === expected &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
// A cold dev server compiles Navara's modules on the first visit.
|
||||
{ timeout: 300_000, polling: 250 },
|
||||
kind,
|
||||
);
|
||||
// The suite owns the globe: no camera flight running, and the app's panels
|
||||
// let the pointer through to the canvas.
|
||||
// The app restores the view in the link with a flight that can start
|
||||
// after the loading screen goes: wait until the camera is there and still,
|
||||
// or that flight lands in the middle of a check.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
return (
|
||||
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
||||
);
|
||||
},
|
||||
{ timeout: 120_000, polling: 250 },
|
||||
);
|
||||
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
||||
// canvas of its own above it.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.host.canvas.setAttribute(
|
||||
'data-contract-target',
|
||||
'',
|
||||
),
|
||||
);
|
||||
await page.addStyleTag({
|
||||
content:
|
||||
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
|
||||
});
|
||||
// The globe drawn and its tiles loaded: a restored view hides the globe
|
||||
// for a moment after the loading screen goes.
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = 1;
|
||||
probe.height = 1;
|
||||
const context = probe.getContext('2d', { willReadFrequently: true });
|
||||
const started = performance.now();
|
||||
let settled = 0;
|
||||
while (performance.now() - started < 120_000 && settled < 20) {
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('contract-suite-settle');
|
||||
});
|
||||
context.drawImage(
|
||||
canvas,
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
settled =
|
||||
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
|
||||
}
|
||||
});
|
||||
// A real left click and pointer move at a canvas position.
|
||||
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
||||
const box = await page.evaluate(() => {
|
||||
const rect =
|
||||
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
||||
return { left: rect.left, top: rect.top };
|
||||
});
|
||||
const at = [box.left + x, box.top + y];
|
||||
if (type === 'click') await page.mouse.click(...at);
|
||||
else {
|
||||
await page.mouse.move(at[0] - 1, at[1]);
|
||||
await page.mouse.move(...at);
|
||||
}
|
||||
});
|
||||
const results = await page.evaluate(
|
||||
async (appOwned, only) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const waitFrames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) return;
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('contract-suite');
|
||||
});
|
||||
const pointer = {
|
||||
click: (x, y) => window.__contractPointer('click', x, y),
|
||||
move: (x, y) => window.__contractPointer('move', x, y),
|
||||
};
|
||||
const { createContractSuite } =
|
||||
await import('/src/globe/testing/contractSuite.mjs');
|
||||
const outcome = [];
|
||||
// A real 2D canvas: a sprite atlas the engines can draw and pick.
|
||||
const makeCanvas = (width, height) =>
|
||||
Object.assign(document.createElement('canvas'), { width, height });
|
||||
// RGBA of the last drawn frame (both engines keep their drawing buffer).
|
||||
const scratch = document.createElement('canvas');
|
||||
const context = scratch.getContext('2d', { willReadFrequently: true });
|
||||
const readRegion = (x, y, width, height) => {
|
||||
const canvas = engine.host.canvas;
|
||||
const scale = canvas.width / canvas.clientWidth;
|
||||
scratch.width = width;
|
||||
scratch.height = height;
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(
|
||||
canvas,
|
||||
x * scale,
|
||||
y * scale,
|
||||
width * scale,
|
||||
height * scale,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return context.getImageData(0, 0, width, height).data;
|
||||
};
|
||||
for (const { name, run } of createContractSuite(engine, {
|
||||
waitFrames,
|
||||
pointer,
|
||||
readRegion,
|
||||
makeCanvas,
|
||||
})) {
|
||||
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
|
||||
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
|
||||
if (owner) {
|
||||
outcome.push({ name, skipped: owner[1] });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await run();
|
||||
outcome.push({ name, ok: true });
|
||||
} catch (error) {
|
||||
outcome.push({
|
||||
name,
|
||||
ok: false,
|
||||
error: String(error?.message ?? error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
APP_OWNED,
|
||||
only,
|
||||
);
|
||||
await page.close();
|
||||
return { engine: kind, results, pageErrors };
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
// A Navara page can hold its main thread for minutes while it starts.
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const report = [];
|
||||
try {
|
||||
for (const kind of engines) {
|
||||
const run = await runOn(browser, kind);
|
||||
report.push(run);
|
||||
const ran = run.results.filter(({ skipped }) => !skipped);
|
||||
const failed = ran.filter(({ ok }) => !ok);
|
||||
console.log(
|
||||
`${kind}: ${ran.length - failed.length}/${ran.length} checks pass`,
|
||||
);
|
||||
for (const { name, skipped } of run.results)
|
||||
if (skipped) console.log(` skipped ${name}: ${skipped}`);
|
||||
for (const { name, error } of failed)
|
||||
console.log(` FAIL ${name}: ${error}`);
|
||||
for (const message of run.pageErrors)
|
||||
console.log(` page error: ${message}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
process.exit(
|
||||
report.every(({ results }) =>
|
||||
results.every(({ ok, skipped }) => ok || skipped),
|
||||
)
|
||||
? 0
|
||||
: 1,
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=55.3241&lon=10.7995&alt=25000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['directions'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
let routeReads = 0,
|
||||
release;
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', async (req) => {
|
||||
const u = new URL(req.url());
|
||||
const json = (v) =>
|
||||
req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
if (u.pathname === '/api/terrain/heights') {
|
||||
const cells = (u.searchParams.get('points') ?? '').split(';');
|
||||
return json({ results: cells.map(() => ({ ellipsoid: 34 })) });
|
||||
}
|
||||
if (u.pathname !== '/api/route') return req.continue();
|
||||
routeReads++;
|
||||
const [a, b] = u.searchParams
|
||||
.get('coords')
|
||||
.split(';')
|
||||
.map((p) => p.split(',').map(Number));
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
if (routeReads === 1)
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return json({
|
||||
ok: true,
|
||||
geometry: [a, mid, b],
|
||||
distanceM: 9000,
|
||||
durationS: 800,
|
||||
steps: [a, mid, b].map(([lon, lat], i) => ({
|
||||
lon,
|
||||
lat,
|
||||
instruction: 'Route step ' + i,
|
||||
distanceM: 3000,
|
||||
durationS: 120,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaRoute = dataManager.layers.get('directions').module;
|
||||
await dataManager.setEnabled('directions', true, { origin: 'user' });
|
||||
});
|
||||
for (const [letter, x] of [
|
||||
['a', 480],
|
||||
['b', 645],
|
||||
]) {
|
||||
await p.evaluate((l) => window.qaRoute.setParams({ arm: l }), letter);
|
||||
await p.mouse.click(x, 440);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
}
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'A and B set by clicks on the globe; one route request',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
release();
|
||||
await p.waitForFunction(() => window.qaRoute.getStats().count === 3);
|
||||
await p.waitForFunction(() => {
|
||||
window.qaRoute.setParams({ step: 1 });
|
||||
return window.qaRoute.getRowControls().list.items[1]?.active;
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'the route asked for on Navara arrives on Cesium; a step is selectable',
|
||||
);
|
||||
const transfer = async (kind) => {
|
||||
await strict.switchTo(p, kind);
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('directions').module ===
|
||||
window.qaRoute,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(routeReads, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'same model; the switch requests no route',
|
||||
);
|
||||
};
|
||||
await transfer('navara');
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
() => window.qaRoute.getRowControls().list.items[1].active,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await p.evaluate(() => window.qaRoute.setParams({ fly: true }));
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')?.active,
|
||||
);
|
||||
const pos = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.pose().position);
|
||||
const d = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
let progress = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const before = await pos();
|
||||
await transfer(kind);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
);
|
||||
const at = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const next = await pos();
|
||||
const resumed = await p.evaluate(() => window.qaRoute.getFlightProgress());
|
||||
assert.equal(resumed.motionId, progress.motionId);
|
||||
assert.ok(resumed.traveledM > progress.traveledM);
|
||||
progress = resumed;
|
||||
console.log(kind, 'moved', d(at, next), 'handoff', d(at, before));
|
||||
assert.ok(d(at, next) > 1, 'dolly advances after switch to ' + kind);
|
||||
await strict.passed(
|
||||
p,
|
||||
'directions',
|
||||
'FLY continues with the same motion and advances',
|
||||
{ moved: d(at, next) },
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => window.qaRoute.setParams({ clear: true }));
|
||||
assert.equal(await p.evaluate(() => window.qaRoute.getStats().count), 0);
|
||||
// CLEAR releases the camera: the route's motion ends, the camera stays
|
||||
// where it is, and a wheel zoom by the person moves it and is kept.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const released = await p.evaluate(() => ({
|
||||
motion: window.qaRoute.getFlightProgress(),
|
||||
fly: window.qaRoute.getRowControls().chips.find((c) => c.id === 'fly')
|
||||
?.active,
|
||||
}));
|
||||
const still = await pos();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const later = await pos();
|
||||
const height = () =>
|
||||
p.evaluate(() => window.__godsEyeView.engine.camera.height());
|
||||
const beforeZoom = await height();
|
||||
await p.mouse.move(512, 300);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await p.mouse.wheel({ deltaY: -240 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const zoomed = await height();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const kept = await height();
|
||||
await strict.check(
|
||||
p,
|
||||
'directions',
|
||||
'CLEAR removes the route and releases the camera: no route motion, the camera stays put, a wheel zoom moves it and is kept',
|
||||
released.motion === null &&
|
||||
!released.fly &&
|
||||
d(still, later) < 1 &&
|
||||
zoomed < beforeZoom * 0.95 &&
|
||||
Math.abs(kept - zoomed) < 0.02 * zoomed,
|
||||
{ released, drift: d(still, later), beforeZoom, zoomed, kept },
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS route delivery during handoff, A/B clicks, step selection, ongoing FLY, clear and single route request',
|
||||
);
|
||||
});
|
||||
+112
-69
@@ -35,7 +35,7 @@ const check = (name, passed, detail = '') => {
|
||||
};
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
headless: process.env.QA_HEADFUL === '1' ? false : true,
|
||||
executablePath: chromeExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
@@ -110,11 +110,7 @@ const layerState = () =>
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
const controls = module.getRowControls();
|
||||
const viewer = gev.viewer;
|
||||
const entities = [...viewer.entities.values];
|
||||
const routeEntities = entities.filter(
|
||||
(entity) => String(entity.id) === 'directions:route',
|
||||
);
|
||||
const drawn = window.__gevQaDirections.drawn();
|
||||
const listNodes = row
|
||||
? [...row.querySelectorAll('.data-row-list-item')]
|
||||
: [];
|
||||
@@ -136,14 +132,9 @@ const layerState = () =>
|
||||
domCurrent: listNodes.findIndex((node) =>
|
||||
node.classList.contains('current'),
|
||||
),
|
||||
routeEntities: routeEntities.length,
|
||||
routePositions:
|
||||
routeEntities[0]?.polyline?.positions?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
)?.length || 0,
|
||||
markerCount: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:marker:'),
|
||||
).length,
|
||||
routeEntities: drawn.routeSets,
|
||||
routePositions: drawn.routeVertices,
|
||||
markerCount: drawn.markersShown,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
cameraMotion: window.__gevQa.getActiveCameraMotion(),
|
||||
};
|
||||
@@ -229,6 +220,96 @@ try {
|
||||
releasePointer: own.releasePointer,
|
||||
getActiveCameraMotion: verbs.getActiveCameraMotion,
|
||||
};
|
||||
// What Directions draws, read through the globe contract
|
||||
// (src/layers/directions/render/display.js): the route is the
|
||||
// `directions-route` ground corridor set, cut into dash pieces; the A/B
|
||||
// discs and the maneuver dots are the `directions-markers` and
|
||||
// `directions-steps` point mover sets. The route's pieces are seen as the
|
||||
// display hands them to engine.ground; which instances are shown is read
|
||||
// from the Cesium engine's own point collection for the set (this gate
|
||||
// drives the Cesium camera).
|
||||
const engine = window.__godsEyeView.engine;
|
||||
let routePieces = null;
|
||||
const createGroundSet = engine.ground.createSet.bind(engine.ground);
|
||||
engine.ground.createSet = (options) => {
|
||||
const set = createGroundSet(options);
|
||||
if (options?.id === 'directions-route') {
|
||||
const replace = set.replace.bind(set);
|
||||
set.replace = (items) => {
|
||||
routePieces = items.map((item) => item.positions.length);
|
||||
return replace(items);
|
||||
};
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const moverPrimitive = (id) => {
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i += 1) {
|
||||
const primitive = collection.get(i);
|
||||
if (!primitive) continue;
|
||||
if (primitive.gevMoverSet === id) return primitive;
|
||||
if (
|
||||
primitive.gevMoverSet === undefined &&
|
||||
typeof primitive.get === 'function' &&
|
||||
typeof primitive.length === 'number'
|
||||
) {
|
||||
const found = walk(primitive);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(window.__godsEyeView.viewer.scene.primitives);
|
||||
};
|
||||
const moverCount = (id) =>
|
||||
engine.movers.list().find((set) => set.id === id)?.count ?? 0;
|
||||
const shownInstances = (id) => {
|
||||
const primitive = moverPrimitive(id);
|
||||
if (!primitive || primitive.show === false) return [];
|
||||
const shown = [];
|
||||
const count = Math.min(moverCount(id), primitive.length);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const instance = primitive.get(i);
|
||||
if (instance.show) shown.push(instance);
|
||||
}
|
||||
return shown;
|
||||
};
|
||||
window.__gevQaDirections = {
|
||||
drawn() {
|
||||
const route = engine.ground
|
||||
.list()
|
||||
.filter((set) => set.id === 'directions-route');
|
||||
// Every original vertex is kept, and each piece adds its two ends,
|
||||
// which are the next piece's start: vertices = total - 2·pieces + 2.
|
||||
const routeVertices =
|
||||
route.length && routePieces?.length
|
||||
? routePieces.reduce((sum, n) => sum + n, 0) -
|
||||
2 * routePieces.length +
|
||||
2
|
||||
: 0;
|
||||
return {
|
||||
routeSets: route.filter((set) => set.shown && set.count > 0).length,
|
||||
routeVertices,
|
||||
markersShown: shownInstances('directions-markers').length,
|
||||
stepCount: moverCount('directions-steps'),
|
||||
stepsShown: shownInstances('directions-steps').map((point) =>
|
||||
point.position.clone(),
|
||||
),
|
||||
};
|
||||
},
|
||||
// Every other layer's contract sets, to prove a disable touches only
|
||||
// the Directions ones.
|
||||
others() {
|
||||
const mine = (id) => String(id).startsWith('directions');
|
||||
return [
|
||||
...engine.movers.list().map((set) => `movers:${set.id}`),
|
||||
...engine.lines.list().map((line) => `lines:${line.id}`),
|
||||
...engine.ground.list().map((set) => `ground:${set.id}`),
|
||||
]
|
||||
.filter((key) => !mine(key.split(':').slice(1).join(':')))
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
});
|
||||
await sleep(3000);
|
||||
|
||||
@@ -700,28 +781,11 @@ try {
|
||||
const denver = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const Cartographic = viewer.camera.positionCartographic.constructor;
|
||||
const scene = viewer.scene;
|
||||
let collection = null;
|
||||
for (let i = 0; i < scene.primitives.length; i += 1) {
|
||||
const primitive = scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
collection = primitive;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!collection) return { dots: 0, shown: 0, heights: [] };
|
||||
const heights = [];
|
||||
for (let i = 0; i < collection.length; i += 1) {
|
||||
const point = collection.get(i);
|
||||
if (!point.show) continue;
|
||||
heights.push(Cartographic.fromCartesian(point.position).height);
|
||||
}
|
||||
return { dots: collection.length, shown: heights.length, heights };
|
||||
const { stepCount, stepsShown } = window.__gevQaDirections.drawn();
|
||||
const heights = stepsShown.map(
|
||||
(position) => Cartographic.fromCartesian(position).height,
|
||||
);
|
||||
return { dots: stepCount, shown: heights.length, heights };
|
||||
});
|
||||
check('Denver has maneuver dots', denver.dots > 0, `${denver.dots} dots`);
|
||||
check(
|
||||
@@ -744,15 +808,10 @@ try {
|
||||
}
|
||||
|
||||
// ── 10. Disable: nothing is left behind. ────────────────────────────────
|
||||
const { beforeDisable, directionsBeforeDisable } = await page.evaluate(() => {
|
||||
const entities = [...window.__godsEyeView.viewer.entities.values];
|
||||
return {
|
||||
beforeDisable: entities.length,
|
||||
directionsBeforeDisable: entities.filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
).length,
|
||||
};
|
||||
});
|
||||
const { othersBefore, drawnBefore } = await page.evaluate(() => ({
|
||||
othersBefore: window.__gevQaDirections.others(),
|
||||
drawnBefore: window.__gevQaDirections.drawn(),
|
||||
}));
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('directions', false, {
|
||||
origin: 'user',
|
||||
@@ -760,39 +819,23 @@ try {
|
||||
);
|
||||
await sleep(1500);
|
||||
const off = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const left = [...viewer.entities.values].filter((entity) =>
|
||||
String(entity.id).startsWith('directions:'),
|
||||
);
|
||||
let shownDots = 0;
|
||||
for (let i = 0; i < viewer.scene.primitives.length; i += 1) {
|
||||
const primitive = viewer.scene.primitives.get(i);
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.get === 'function' &&
|
||||
primitive.length > 0 &&
|
||||
String(primitive.get(0)?.id || '').startsWith('directions:step:')
|
||||
) {
|
||||
shownDots = primitive.show ? primitive.length : 0;
|
||||
}
|
||||
}
|
||||
const drawn = window.__gevQaDirections.drawn();
|
||||
const row = document.querySelector(
|
||||
'#utilities-toggles [data-layer-id="directions"]',
|
||||
);
|
||||
return {
|
||||
entities: left.length,
|
||||
shownDots,
|
||||
entities: drawn.routeSets + drawn.markersShown,
|
||||
shownDots: drawn.stepsShown.length,
|
||||
controlsHidden:
|
||||
row?.querySelector('.data-toggle-controls')?.hidden === true,
|
||||
listHidden: row?.querySelector('.data-row-list')?.hidden === true,
|
||||
pointerOwner: window.__gevQa.pointerOwner(),
|
||||
motion: window.__gevQa.getActiveCameraMotion(),
|
||||
totalEntities: viewer.entities.values.length,
|
||||
others: window.__gevQaDirections.others(),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'disabling removes every Directions entity',
|
||||
'disabling removes the route and hides both markers',
|
||||
off.entities === 0,
|
||||
`${off.entities} left`,
|
||||
);
|
||||
@@ -809,9 +852,9 @@ try {
|
||||
);
|
||||
check('disabling leaves no camera motion running', off.motion === null);
|
||||
check(
|
||||
'exactly the Directions entities went away, and nothing else did',
|
||||
off.totalEntities === beforeDisable - directionsBeforeDisable,
|
||||
`${beforeDisable} total (${directionsBeforeDisable} ours) -> ${off.totalEntities}`,
|
||||
"only the Directions drawing went away: every other layer's sets stay",
|
||||
JSON.stringify(off.others) === JSON.stringify(othersBefore),
|
||||
`ours before: route ${drawnBefore.routeSets}, markers ${drawnBefore.markersShown}, dots ${drawnBefore.stepsShown.length}; others ${othersBefore.length} -> ${off.others.length}`,
|
||||
);
|
||||
|
||||
check(
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
const url = localPreview(process.argv[2]);
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=30.2672&lon=-97.742&alt=4000&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 64, channels: 4, background: '#fd2233' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const features = [
|
||||
{
|
||||
id: 'point',
|
||||
geometry: { type: 'Point', coordinates: [-97.742, 30.27, 450] },
|
||||
},
|
||||
{
|
||||
id: 'line',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-97.745, 30.268, 400],
|
||||
[-97.735, 30.268, 400],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'polygon',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-97.747, 30.264, 450],
|
||||
[-97.74, 30.264, 450],
|
||||
[-97.74, 30.267, 450],
|
||||
[-97.747, 30.264, 450],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
].map((f) => ({ ...f, type: 'Feature' }));
|
||||
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 pack = (id, format, path, placement) => ({
|
||||
id,
|
||||
version: 1,
|
||||
format,
|
||||
source: { adapter: 'assets', path },
|
||||
attribution: { text: 'Local fixture', license: 'CC0-1.0' },
|
||||
placement,
|
||||
});
|
||||
const camera = {
|
||||
lat: 30.2672,
|
||||
lon: -97.742,
|
||||
alt: 4000,
|
||||
heading: 0,
|
||||
pitch: -90,
|
||||
roll: 0,
|
||||
altitudeReference: 'ellipsoid',
|
||||
};
|
||||
const shot = {
|
||||
id: 'one',
|
||||
title: 'Pack view',
|
||||
camera,
|
||||
durationSec: 5,
|
||||
holdSec: 3,
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
layers: { earthquakes: true },
|
||||
dataPackIds: ['data', 'image', 'audio'],
|
||||
interactions: features.map((f) => ({
|
||||
id: f.id,
|
||||
label: 'Show ' + f.id,
|
||||
target: { packId: 'data', featureId: f.id },
|
||||
action: { type: 'card', text: f.id },
|
||||
})),
|
||||
};
|
||||
const project = {
|
||||
version: 6,
|
||||
scenes: [
|
||||
{
|
||||
id: 'parity',
|
||||
title: 'Test project',
|
||||
anchors: [
|
||||
{
|
||||
id: 'station',
|
||||
lat: 30.271,
|
||||
lon: -97.735,
|
||||
alt: 500,
|
||||
altitudeReference: 'ellipsoid',
|
||||
},
|
||||
],
|
||||
dataPacks: [
|
||||
pack('data', 'geojson', 'data.json', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
}),
|
||||
pack('image', 'image', 'image.png', {
|
||||
altitudeReference: 'ellipsoid',
|
||||
bounds: [-97.738, 30.264, -97.732, 30.267],
|
||||
height: 500,
|
||||
}),
|
||||
pack('audio', 'media', 'audio.wav', { anchorId: 'station' }),
|
||||
],
|
||||
shots: [
|
||||
shot,
|
||||
{
|
||||
...shot,
|
||||
id: 'two',
|
||||
title: 'Next shot',
|
||||
camera: { ...camera, heading: 15 },
|
||||
move: { from: camera, easing: 'linear' },
|
||||
dataPackIds: [],
|
||||
interactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// Scene Director is not a catalog layer; the scenario answers the scene's
|
||||
// earthquake feed itself.
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [],
|
||||
fixtures: ['https://earthquake.usgs.gov/*'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => {
|
||||
errors.push(e.message);
|
||||
console.log('error', e.message);
|
||||
});
|
||||
const reads = [];
|
||||
await p.setRequestInterception(true);
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
if (path.startsWith('/scene-assets/')) {
|
||||
reads.push(path);
|
||||
const file = path.split('/').pop();
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType:
|
||||
file === 'data.json'
|
||||
? 'application/geo+json'
|
||||
: file === 'audio.wav'
|
||||
? 'audio/wav'
|
||||
: 'image/png',
|
||||
body:
|
||||
file === 'data.json'
|
||||
? JSON.stringify({ type: 'FeatureCollection', features })
|
||||
: file === 'audio.wav'
|
||||
? wav
|
||||
: png,
|
||||
});
|
||||
}
|
||||
if (new URL(req.url()).hostname === 'earthquake.usgs.gov')
|
||||
return req.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: '{"type":"FeatureCollection","features":[]}',
|
||||
});
|
||||
return req.continue();
|
||||
});
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.sceneDirector &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (project) => {
|
||||
const a = window.__godsEyeView;
|
||||
|
||||
await a.styleManager.initialRestorePromise;
|
||||
window.director = a.sceneDirector;
|
||||
await window.director.importProjectFile(
|
||||
new File([JSON.stringify(project)], 'qa.json'),
|
||||
);
|
||||
const out = await window.director.loadShot('parity', 'one', {
|
||||
flyDuration: 0.2,
|
||||
});
|
||||
if (!out.started)
|
||||
throw Error(
|
||||
'Pack load failed: ' +
|
||||
JSON.stringify(out) +
|
||||
' ' +
|
||||
window.director._presentation.status,
|
||||
);
|
||||
}, project);
|
||||
await p.waitForSelector('[data-director-pack="audio"] audio');
|
||||
await p.evaluate(() => {
|
||||
window.qaAudio = document.querySelector(
|
||||
'[data-director-pack="audio"] audio',
|
||||
);
|
||||
});
|
||||
const at = (world) =>
|
||||
p.evaluate((world) => {
|
||||
const a = window.__godsEyeView;
|
||||
const xy = a.engine.projector.worldToWindow(world),
|
||||
rect = a.engine.host.canvas.getBoundingClientRect();
|
||||
return { x: xy.x + rect.x, y: xy.y + rect.y };
|
||||
}, world);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind)
|
||||
await strict.switchTo(p, kind);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const st = await p.evaluate(() => ({
|
||||
same: window.director === window.__godsEyeView.sceneDirector,
|
||||
media:
|
||||
window.qaAudio ===
|
||||
document.querySelector('[data-director-pack="audio"] audio'),
|
||||
packs: window.director.getDataPackState(),
|
||||
actions: window.director.getInteractionState(),
|
||||
}));
|
||||
assert.ok(
|
||||
st.same && st.media && st.packs.count === 3 && st.actions.count === 3,
|
||||
);
|
||||
const xy = await at(fromDegrees(-97.742, 30.27, 450));
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await p.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'point',
|
||||
);
|
||||
await p.keyboard.press('Enter');
|
||||
await p.waitForFunction(
|
||||
() => window.director.getInteractionState().selected === 'point',
|
||||
);
|
||||
for (const [id, coords] of [
|
||||
['line', [-97.74, 30.268, 400]],
|
||||
['polygon', [-97.742, 30.265, 450]],
|
||||
]) {
|
||||
const hit = await at(fromDegrees(...coords));
|
||||
await p.mouse.click(hit.x, hit.y);
|
||||
await p.waitForFunction(
|
||||
(id) => document.activeElement?.dataset.directorAction === id,
|
||||
{ timeout: 10000 },
|
||||
id,
|
||||
);
|
||||
}
|
||||
const imageAt = await at(fromDegrees(-97.735, 30.2655, 500));
|
||||
const sample = await sharp(await p.screenshot())
|
||||
.extract({
|
||||
left: Math.round(imageAt.x),
|
||||
top: Math.round(imageAt.y),
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
console.log(kind, st.packs.count, st.actions.count, Array.from(sample));
|
||||
assert.ok(
|
||||
sample[0] > 100 && sample[0] > sample[1] * 2,
|
||||
'authored image really draws',
|
||||
);
|
||||
assert.equal(
|
||||
reads.length,
|
||||
3,
|
||||
'packs are not acquired again on a globe change',
|
||||
);
|
||||
}
|
||||
await p.evaluate(() => {
|
||||
window.qaRun = window.director.startScene('parity', {
|
||||
single: true,
|
||||
preview: false,
|
||||
});
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director.getPlaybackTimingState().snapshot?.shotId === 'one',
|
||||
);
|
||||
await strict.switchTo(p, 'cesium');
|
||||
assert.ok(
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.director.running &&
|
||||
window.director === window.__godsEyeView.sceneDirector,
|
||||
),
|
||||
);
|
||||
await p.waitForFunction(
|
||||
() => window.director.getPlaybackTimingState().snapshot?.shotId === 'two',
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
await strict.switchTo(p, 'navara');
|
||||
const complete = await p.evaluate(() => window.qaRun);
|
||||
assert.ok(complete.started && complete.shots === 2);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS authored packs, geographic picks, same player, original multi-shot timeline',
|
||||
);
|
||||
});
|
||||
@@ -47,7 +47,9 @@ const load = () =>
|
||||
try {
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
// A view in the link: the app starts there instead of flying to a city
|
||||
// after the shot has placed the camera.
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0#v=2&lat=30.2672&lon=-97.742&alt=4000&heading=0&pitch=-90&l=`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
@@ -105,7 +107,7 @@ try {
|
||||
from: { ...camera, alt: 4100, altitudeReference: 'ellipsoid' },
|
||||
easing: 'linear',
|
||||
},
|
||||
visual: { style: 'normal', mapStack: 'photoreal' },
|
||||
visual: { style: 'normal', mapStack: 'sentinel2' },
|
||||
layers: { traffic: false },
|
||||
dataPackIds: ['data'],
|
||||
interactions: [
|
||||
@@ -162,21 +164,26 @@ try {
|
||||
),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
() => window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
{ 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();
|
||||
const { cartesian3 } = await import('/src/geo/index.js');
|
||||
const pos = cartesian3.fromDegrees(-97.742, 30.2672, 600);
|
||||
const screen = d.engine.projector.worldToWindow(pos);
|
||||
const rect = d.engine.host.canvas.getBoundingClientRect();
|
||||
return { x: screen.x + rect.left, y: screen.y + rect.top };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
// The click's pick answers asynchronously; the focus follows it.
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => document.activeElement?.dataset.directorAction === 'card',
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
check(
|
||||
'real globe pick selects the feature and focuses its keyboard action',
|
||||
await page.evaluate(
|
||||
@@ -204,7 +211,8 @@ try {
|
||||
`${output}/report.json`,
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
tilesSettled: window.__godsEyeView.tileset?.tilesLoaded === true,
|
||||
tilesSettled:
|
||||
window.__godsEyeView.sceneDirector.engine.surface.tilesLoaded(),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
@@ -217,8 +225,7 @@ try {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.sceneDirector.viewer.camera.positionCartographic
|
||||
.height - 5000,
|
||||
window.__godsEyeView.sceneDirector.engine.camera.height() - 5000,
|
||||
) < 1,
|
||||
),
|
||||
);
|
||||
|
||||
+126
-98
@@ -37,6 +37,21 @@ const browser = await puppeteer.launch({
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
// Daylight over San Francisco: the amber-pixel checks count the drawn mark,
|
||||
// and the globe's night side shows warm city lights the count would include.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const offset = Date.UTC(2026, 5, 15, 20, 0) - Date.now();
|
||||
const RealDate = Date;
|
||||
globalThis.Date = class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
};
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||
let failures = 0;
|
||||
@@ -189,7 +204,12 @@ async function waitForMarks(expected, timeout = 15_000) {
|
||||
|
||||
try {
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||
// The view in the address too: the boot flight lands there instead of
|
||||
// flying away from the view set below once deferred init runs.
|
||||
await page.goto(
|
||||
`${BASE_URL}/?welcome=0#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.height}&heading=${VIEW.heading}&pitch=${VIEW.pitch}`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevDrawTool &&
|
||||
@@ -309,13 +329,12 @@ try {
|
||||
check(
|
||||
'three enable/disable cycles add no listener, handler or preview data source',
|
||||
after.domListeners === before.domListeners &&
|
||||
after.previewDataSources === 1 &&
|
||||
before.previewDataSources === 1 &&
|
||||
after.previewDataSources === 0 &&
|
||||
before.previewDataSources === 0 &&
|
||||
after.sceneHandler === false &&
|
||||
after.pointerOwner === null &&
|
||||
after.stockDoubleClick === before.stockDoubleClick,
|
||||
after.pointerOwner === null,
|
||||
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
|
||||
`handler ${after.sceneHandler}, owner ${after.pointerOwner}`,
|
||||
);
|
||||
|
||||
// ── the viewer's own click actions are borrowed, then given back ────────
|
||||
@@ -601,24 +620,46 @@ try {
|
||||
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
|
||||
);
|
||||
if (target) {
|
||||
// Layers select through their own state now, not the viewer's
|
||||
// selectedEntity: a CCTV marker makes its camera the active one, a data
|
||||
// centre selects its record in the shared context store.
|
||||
const isSelected = (spot) =>
|
||||
page.evaluate((spot) => {
|
||||
if (spot.kind === 'cctv')
|
||||
return (
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('cctv')
|
||||
?.module?.getUIState?.()?.activeCameraId === spot.id
|
||||
);
|
||||
return window.__gevContextStore?.selectedEntityId === spot.id;
|
||||
}, spot);
|
||||
// Clear the selection (another camera active; no context selection), as
|
||||
// the old check cleared the viewer's selectedEntity.
|
||||
const deselect = (spot) =>
|
||||
page.evaluate((spot) => {
|
||||
if (spot.kind === 'cctv') {
|
||||
const module =
|
||||
window.__godsEyeView.dataManager.layers.get('cctv')?.module;
|
||||
const other = (module?.getUIState?.()?.cameras || []).find(
|
||||
(camera) => camera.id !== spot.id,
|
||||
);
|
||||
if (other) module.selectCamera(other.id);
|
||||
} else if (window.__gevContextStore)
|
||||
window.__gevContextStore.selectedEntityId = null;
|
||||
}, spot);
|
||||
await deselect(target);
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const selectedNormally = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
const selectedNormally = await isSelected(target);
|
||||
check(
|
||||
`clicking a ${target.layer} selects it when nothing owns the pointer`,
|
||||
selectedNormally,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.viewer.selectedEntity = undefined;
|
||||
});
|
||||
await deselect(target);
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(target.x, target.y, { settle: 900 });
|
||||
const whileDrawing = await drawState();
|
||||
const selectedWhileDrawing = await page.evaluate(() =>
|
||||
Boolean(window.__godsEyeView.viewer.selectedEntity),
|
||||
);
|
||||
const selectedWhileDrawing = await isSelected(target);
|
||||
check(
|
||||
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
|
||||
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
|
||||
@@ -677,19 +718,37 @@ try {
|
||||
null,
|
||||
};
|
||||
});
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
// The reload restores the view in the address (the clicked CCTV camera's
|
||||
// flight left a low, near-horizontal view there) with a boot flight that can
|
||||
// land after a setView: set the view until it holds.
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (view.lon * Math.PI) / 180,
|
||||
latitude: (view.lat * Math.PI) / 180,
|
||||
height: view.height,
|
||||
}),
|
||||
orientation: {
|
||||
heading: 0,
|
||||
pitch: (view.pitch * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
const held = await page.evaluate((view) => {
|
||||
const carto = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return (
|
||||
Math.abs((carto.latitude * 180) / Math.PI - view.lat) < 0.01 &&
|
||||
Math.abs((carto.longitude * 180) / Math.PI - view.lon) < 0.01 &&
|
||||
Math.abs(carto.height - view.height) < view.height * 0.2
|
||||
);
|
||||
}, VIEW);
|
||||
if (held) break;
|
||||
}
|
||||
await page.click('#draw-toggle');
|
||||
await page.click('.pp-mode-btn[data-shape="area"]');
|
||||
await clickWorld(420, 280);
|
||||
@@ -813,7 +872,7 @@ try {
|
||||
finalState.active === false &&
|
||||
finalState.pointerOwner === null &&
|
||||
finalState.sceneHandler === false &&
|
||||
finalState.previewDataSources === 1,
|
||||
finalState.previewDataSources === 0,
|
||||
JSON.stringify(finalState),
|
||||
);
|
||||
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
|
||||
@@ -859,9 +918,9 @@ try {
|
||||
JSON.stringify(destroyed.diagnostics),
|
||||
);
|
||||
check(
|
||||
'destroy() detaches the preview data source and gives the window handle back',
|
||||
'destroy() leaves no preview data source and gives the window handle back',
|
||||
destroyed.previews === 0 &&
|
||||
destroyed.dataSources === beforeDestroy - 1 &&
|
||||
destroyed.dataSources === beforeDestroy &&
|
||||
destroyed.handleGone,
|
||||
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
|
||||
);
|
||||
@@ -930,17 +989,15 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, camera);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
|
||||
);
|
||||
if (spot) return { ...spot, layer: 'CCTV camera' };
|
||||
const spot = await onScreen(['cctv']);
|
||||
if (spot) return { ...spot, kind: 'cctv', layer: 'CCTV camera' };
|
||||
}
|
||||
|
||||
// Bundled infrastructure as the keyless fallback: a different guarded handler,
|
||||
// the same shared claim, and always present without a key.
|
||||
// the same shared claim, and always present without a key. The layer
|
||||
// registers each site in the shared context store with its coordinates.
|
||||
const site = await page
|
||||
.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
await window.__godsEyeView.dataManager.setEnabled(
|
||||
'local-datacenters',
|
||||
true,
|
||||
@@ -948,30 +1005,15 @@ async function findClickableTarget() {
|
||||
origin: 'user',
|
||||
},
|
||||
);
|
||||
const centreOf = (entity) => {
|
||||
const time = viewer.clock.currentTime;
|
||||
const point = entity.position?.getValue?.(time);
|
||||
if (point) return point;
|
||||
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
|
||||
if (hierarchy?.positions?.length) return hierarchy.positions[0];
|
||||
const line = entity.polyline?.positions?.getValue?.(time);
|
||||
return line?.length ? line[0] : null;
|
||||
};
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
for (const entity of viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId !== 'local-datacenters') continue;
|
||||
const point = centreOf(entity);
|
||||
if (!point) continue;
|
||||
const carto = window.Cesium
|
||||
? window.Cesium.Cartographic.fromCartesian(point)
|
||||
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
|
||||
if (!carto) continue;
|
||||
return {
|
||||
lon: (carto.longitude * 180) / Math.PI,
|
||||
lat: (carto.latitude * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
for (const record of window.__gevContextStore?.entities.values() ??
|
||||
[]) {
|
||||
if (record.layerId !== 'local-datacenters') continue;
|
||||
if (
|
||||
Number.isFinite(record.latitude) &&
|
||||
Number.isFinite(record.longitude)
|
||||
)
|
||||
return { lon: record.longitude, lat: record.latitude };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
@@ -993,54 +1035,40 @@ async function findClickableTarget() {
|
||||
viewer.scene.requestRender();
|
||||
}, site);
|
||||
await wait(4000);
|
||||
const spot = await onScreen(
|
||||
(entity) => entity.__localLayerId === 'local-datacenters',
|
||||
);
|
||||
return spot ? { ...spot, layer: 'data centre' } : null;
|
||||
const spot = await onScreen([
|
||||
'local-datacenters-tips',
|
||||
'local-datacenters-pins',
|
||||
]);
|
||||
return spot ? { ...spot, kind: 'context', layer: 'data centre' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas coordinates of the first entity matching `belongs` that is on screen
|
||||
* and clear of the side panels. Annotation marks and the draw tool's own
|
||||
* preview vertices are on screen too, so the predicate has to be specific.
|
||||
* Canvas coordinates of a spot clear of the side panels where the engine's
|
||||
* pick returns an instance of one of the mover sets `owners` (layers draw
|
||||
* through engine.movers; a pick reports the set as `owner` and the instance's
|
||||
* pickId as `id`). Picking, not projecting, proves the spot is clickable: an
|
||||
* instance beyond the horizon or behind a building does not count.
|
||||
*/
|
||||
async function onScreen(belongs, timeout = 20_000) {
|
||||
async function onScreen(owners, timeout = 20_000) {
|
||||
return page
|
||||
.waitForFunction(
|
||||
(belongsSource) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const belongsTo = new Function(`return (${belongsSource})`)();
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const scene = viewer.scene;
|
||||
const collections = [viewer.entities];
|
||||
for (let i = 0; i < viewer.dataSources.length; i += 1) {
|
||||
const source = viewer.dataSources.get(i);
|
||||
if (source?.name === 'gev-draw-preview') continue;
|
||||
collections.push(source.entities);
|
||||
}
|
||||
for (const source of collections) {
|
||||
for (const entity of source.values) {
|
||||
if (!belongsTo(entity)) continue;
|
||||
const position = entity.position?.getValue?.(
|
||||
viewer.clock.currentTime,
|
||||
);
|
||||
if (!position) continue;
|
||||
const window2d = scene.cartesianToCanvasCoordinates(position);
|
||||
if (!window2d) continue;
|
||||
const { x, y } = window2d;
|
||||
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
|
||||
// Projecting into the window is not the same as being clickable:
|
||||
// the entity may be beyond the horizon or behind a building. Only a
|
||||
// spot where the scene actually picks it proves the control case.
|
||||
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
|
||||
if (!hit?.id || !belongsTo(hit.id)) continue;
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
(wanted) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const sets = engine.movers
|
||||
.list()
|
||||
.filter((set) => wanted.includes(set.id) && set.count > 0);
|
||||
if (!sets.length) return false;
|
||||
for (let y = 190; y < 600; y += 20) {
|
||||
for (let x = 310; x < 900; x += 20) {
|
||||
const hit = engine.picker.pick({ x, y });
|
||||
if (hit && wanted.includes(hit.owner) && hit.id != null)
|
||||
return { x, y, id: String(hit.id) };
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout, polling: 500 },
|
||||
belongs.toString(),
|
||||
{ timeout, polling: 1000 },
|
||||
owners,
|
||||
)
|
||||
.then(
|
||||
(handle) => handle.jsonValue(),
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=42.3554&lon=-71.0605&alt=6000&heading=0&pitch=-90&map=osm&l=e';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['earthquakes'] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
const fetch = window.fetch.bind(window);
|
||||
// Two Boston vessels for the Contacts session; empty air and site feeds.
|
||||
const fixtures = {
|
||||
'/api/vessels': () => ({
|
||||
status: 'live',
|
||||
rows: ['123456789', '223456789'].map((mmsi, i) => ({
|
||||
mmsi,
|
||||
name: 'Boston ' + i,
|
||||
lat: 42.3554,
|
||||
lon: -71.0605 + i * 0.05,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
})),
|
||||
}),
|
||||
'/api/vessels/track': () => ({ samples: [] }),
|
||||
'/api/flights': () => ({ time: Date.now() / 1000, states: [] }),
|
||||
'/api/military': () => ({ now: Date.now(), ac: [] }),
|
||||
'/api/military-installations': () => ({ elements: [], status: 'ok' }),
|
||||
};
|
||||
window.fetch = (url, options) => {
|
||||
if (
|
||||
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/')
|
||||
)
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
id: 'qa-boston',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [-71.0605, 42.3554, 8],
|
||||
},
|
||||
properties: { mag: 4, time: Date.now(), place: 'QA Boston' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture =
|
||||
fixtures[new URL(url.url ?? url, document.baseURI).pathname];
|
||||
return fixture
|
||||
? Promise.resolve(Response.json(fixture()))
|
||||
: fetch(url, options);
|
||||
};
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
async function ready(kind) {
|
||||
await page.waitForFunction(
|
||||
(kind) =>
|
||||
window.__godsEyeView?.engine?.kind === kind &&
|
||||
document.querySelector('#globe-engine')?.value === kind &&
|
||||
!document.querySelector(
|
||||
'#globe-engine:is([disabled], [aria-busy="true"])',
|
||||
) &&
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
await page.evaluate(async () => {
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
});
|
||||
assert.equal(await page.$eval('#globe-engine', (el) => el.value), kind);
|
||||
}
|
||||
async function verifyQuakeSurface(kind) {
|
||||
const meanRed = async (enabled) => {
|
||||
await page.evaluate(async (enabled) => {
|
||||
const a = window.__godsEyeView;
|
||||
await a.dataManager.setEnabled('earthquakes', enabled, {
|
||||
origin: 'user',
|
||||
});
|
||||
a.engine.render.request('qa-quake-surface');
|
||||
}, enabled);
|
||||
if (enabled)
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('earthquakes')
|
||||
.module.getAnalystRecords().length === 1,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
const bytes = await page.screenshot({
|
||||
clip: { x: 675, y: 403, width: 20, height: 20 },
|
||||
});
|
||||
const { channels } = await sharp(bytes).stats();
|
||||
return channels[0].mean - channels[1].mean;
|
||||
};
|
||||
const off = await meanRed(false),
|
||||
on = await meanRed(true);
|
||||
assert.ok(
|
||||
on > off + 8,
|
||||
kind +
|
||||
' must draw the shallow red earthquake footprint: ' +
|
||||
off +
|
||||
' -> ' +
|
||||
on,
|
||||
);
|
||||
await strict.passed(page, 'earthquakes', 'shallow red footprint drawn', {
|
||||
off,
|
||||
on,
|
||||
});
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'earthquake footprint red minus green:',
|
||||
off,
|
||||
'->',
|
||||
on,
|
||||
);
|
||||
}
|
||||
const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
|
||||
async function switchTo(kind) {
|
||||
if (
|
||||
await page.$eval(
|
||||
'#control-panel-toggle',
|
||||
(el) => el.getAttribute('aria-expanded') !== 'true',
|
||||
)
|
||||
) {
|
||||
await page.focus('#control-panel-toggle');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await page.waitForSelector('#globe-engine', { visible: true });
|
||||
await page.evaluate(() => {
|
||||
window.__switchDocument = document;
|
||||
window.__quakeEntry =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
window.__quakeTimer = window.__quakeEntry.intervalId;
|
||||
window.__renderers ??= new Map();
|
||||
window.__renderers.set(
|
||||
window.__godsEyeView.engine.kind,
|
||||
window.__godsEyeView.engine,
|
||||
);
|
||||
window.__switchSplash = false;
|
||||
window.__switchObserver = new MutationObserver(() => {
|
||||
if (!document.querySelector('#loading-screen.hidden'))
|
||||
window.__switchSplash = true;
|
||||
});
|
||||
window.__switchObserver.observe(
|
||||
document.querySelector('#loading-screen'),
|
||||
{ attributes: true },
|
||||
);
|
||||
window.__outgoingEngine = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
assert.ok(await page.evaluate(() => window.__switchDocument === document));
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
window.__switchObserver.disconnect();
|
||||
const { engine } = window.__godsEyeView;
|
||||
const previous = window.__renderers.get(engine.kind);
|
||||
return (
|
||||
!window.__switchSplash &&
|
||||
window.__quakeEntry ===
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
|
||||
window.__quakeTimer === window.__quakeEntry.intervalId &&
|
||||
(!previous || previous === engine) &&
|
||||
!window.__outgoingEngine.isDestroyed() &&
|
||||
!window.__outgoingEngine.host.canvas.isConnected &&
|
||||
engine.host.canvas.isConnected &&
|
||||
window.__godsEyeView.dataManager
|
||||
.getEnabledLayerIds()
|
||||
.has('earthquakes')
|
||||
);
|
||||
}),
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'same layer entry, model and poll timer; no splash; outgoing canvas detached',
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(async () => {
|
||||
const parked = window.__outgoingEngine;
|
||||
let frames = 0;
|
||||
const remove = parked.frames.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
});
|
||||
parked.render.request('qa-parked');
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
remove();
|
||||
return frames;
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const url = new URL(page.url());
|
||||
assert.equal(url.searchParams.get('engine'), kind);
|
||||
assert.equal(url.searchParams.get('look'), 'cinematic');
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assert.equal(hash.get('map'), 'osm');
|
||||
assert.ok(Math.abs(Number(hash.get('lat')) - 42.3554) < 0.01);
|
||||
assert.ok(Math.abs(Number(hash.get('lon')) + 71.0605) < 0.01);
|
||||
assert.ok(Math.abs(Number(hash.get('alt')) - 6000) < 30);
|
||||
const view = await page.evaluate(() => {
|
||||
const { engine, mapStackController } = window.__godsEyeView;
|
||||
const { latitude, longitude, height } = engine.camera.pose().cartographic;
|
||||
return {
|
||||
latitude: latitude * (180 / Math.PI),
|
||||
longitude: longitude * (180 / Math.PI),
|
||||
height,
|
||||
map: mapStackController.getActiveId(),
|
||||
};
|
||||
});
|
||||
assert.equal(view.map, 'osm');
|
||||
assert.ok(Math.abs(view.latitude - 42.3554) < 0.01);
|
||||
assert.ok(Math.abs(view.longitude + 71.0605) < 0.01);
|
||||
assert.ok(Math.abs(view.height - 6000) < 30);
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'parked engine draws no frame; pose, map and layers carried over',
|
||||
);
|
||||
}
|
||||
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||
await ready('cesium');
|
||||
await verifyQuakeSurface('cesium');
|
||||
const sharpen = () =>
|
||||
page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const style = window.__godsEyeView.styleManager;
|
||||
style.setStyle('retro');
|
||||
style.setStyle('normal');
|
||||
});
|
||||
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 49 });
|
||||
await switchTo('navara');
|
||||
await verifyQuakeSurface('navara');
|
||||
assert.deepEqual(await sharpen(), { enabled: true, intensityPct: 80 });
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
Math.abs(window.__godsEyeView.engine.render.sharpen().amount - 1.7) <
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.equal(await stored(), 'navara');
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
await ready('cesium');
|
||||
assert.equal((await sharpen()).intensityPct, 49);
|
||||
await page.goForward();
|
||||
await ready('navara');
|
||||
assert.equal((await sharpen()).intensityPct, 80);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setSharpen({
|
||||
enabled: false,
|
||||
intensityPct: 49,
|
||||
}),
|
||||
);
|
||||
await switchTo('cesium');
|
||||
await switchTo('navara');
|
||||
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
|
||||
// The address carries the running layer set once its debounced write lands.
|
||||
const settledAddress = async () => {
|
||||
let previous;
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const current = await page.evaluate(() =>
|
||||
new URLSearchParams(location.hash.slice(1)).get('l'),
|
||||
);
|
||||
if (current === previous) return current;
|
||||
previous = current;
|
||||
}
|
||||
};
|
||||
const pick = async (kind) => {
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
};
|
||||
const visit = async (direction, kind) => {
|
||||
await (direction === 'back'
|
||||
? page.goBack({ waitUntil: 'domcontentloaded' })
|
||||
: page.goForward());
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.engine-switch-frame'),
|
||||
);
|
||||
};
|
||||
const quakes = () =>
|
||||
page.evaluate(() => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
return {
|
||||
model: window.__quakeModel === dataManager.layers.get('earthquakes'),
|
||||
enabled: dataManager.getEnabledLayerIds().has('earthquakes'),
|
||||
};
|
||||
});
|
||||
// Like a same-engine Back, each entry brings back its own layer set, on
|
||||
// the same layer models.
|
||||
await page.evaluate(() => {
|
||||
window.__quakeModel =
|
||||
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||
});
|
||||
const navaraLayers = await settledAddress();
|
||||
await pick('cesium');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('earthquakes', false, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
const cesiumLayers = await settledAddress();
|
||||
assert.notEqual(cesiumLayers, navaraLayers);
|
||||
await visit('back', 'navara');
|
||||
assert.deepEqual(await quakes(), { model: true, enabled: true });
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'Back restores the enabled layer on the same model',
|
||||
);
|
||||
assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry');
|
||||
await visit('forward', 'cesium');
|
||||
assert.deepEqual(await quakes(), { model: true, enabled: false });
|
||||
await strict.passed(
|
||||
page,
|
||||
'earthquakes',
|
||||
'Forward restores the disabled layer on the same model',
|
||||
);
|
||||
assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry');
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('earthquakes', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await pick('navara');
|
||||
console.log(
|
||||
"PASS Back/Forward restore each entry's layers on the same models",
|
||||
);
|
||||
// Back/Forward hand the session off like the picker: the Contacts subject
|
||||
// and its navigation history, its layers and the Director survive.
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('ais-live-vessels', true, {
|
||||
origin: 'user',
|
||||
});
|
||||
const outcome = await styleManager.setContextMode('contacts');
|
||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||
window.qaSession = {
|
||||
director: sceneDirector,
|
||||
context: dataManager.layers.get('military-awareness').module,
|
||||
vessels: dataManager.layers.get('ais-live-vessels'),
|
||||
};
|
||||
if (!window.qaSession.vessels.module.selectById('123456789'))
|
||||
throw Error('Missing first vessel');
|
||||
if (
|
||||
!window.qaSession.context.navigateNext({
|
||||
targetLayer: 'ais-live-vessels',
|
||||
})
|
||||
)
|
||||
throw Error('Cannot navigate to the second vessel');
|
||||
});
|
||||
const session = () =>
|
||||
page.evaluate(() => {
|
||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||
const { director, context, vessels } = window.qaSession;
|
||||
return {
|
||||
director: director === sceneDirector && !director._destroyed,
|
||||
owners:
|
||||
context === dataManager.layers.get('military-awareness').module &&
|
||||
vessels === dataManager.layers.get('ais-live-vessels'),
|
||||
mode: styleManager.getContextModeState().mode,
|
||||
subject: context.getContextSnapshot()?.subject?.id,
|
||||
history: context._getAwarenessNavigationStateForTest().historyKeys,
|
||||
enabled: [...dataManager.getEnabledLayerIds()].sort(),
|
||||
};
|
||||
});
|
||||
const live = await session();
|
||||
assert.equal(live.mode, 'flights');
|
||||
assert.equal(live.subject, '223456789');
|
||||
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
||||
await settledAddress();
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await pick('cesium');
|
||||
assert.deepEqual(await session(), live, 'a pick keeps the session');
|
||||
await settledAddress();
|
||||
await visit('back', 'navara');
|
||||
assert.deepEqual(await session(), live, 'Back keeps the session');
|
||||
await visit('forward', 'cesium');
|
||||
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
||||
assert.equal(await page.evaluate(() => history.length), historyLength + 1);
|
||||
assert.ok(
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
const result = await styleManager.setContextMode('off');
|
||||
await dataManager.setEnabled('ais-live-vessels', false, {
|
||||
origin: 'user',
|
||||
});
|
||||
return result.ok;
|
||||
}),
|
||||
);
|
||||
await pick('navara');
|
||||
console.log(
|
||||
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
||||
);
|
||||
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||
await ready('navara');
|
||||
await switchTo('cesium');
|
||||
assert.equal(await stored(), 'cesium');
|
||||
await page.evaluate(() => {
|
||||
const write = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
if (key === 'gev.engine')
|
||||
throw new DOMException('Blocked', 'SecurityError');
|
||||
return write.call(this, key, value);
|
||||
};
|
||||
});
|
||||
await switchTo('navara');
|
||||
assert.equal(await stored(), 'cesium');
|
||||
// The real Map Source chips move into daylight inspection and back to live lighting.
|
||||
await page.$eval('[data-stack-id="sentinel2"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'sentinel2' &&
|
||||
window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
// Zooming out on the satellite map must keep the same inspection sun.
|
||||
assert.ok(
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.camera.zoomOut(2_000_000);
|
||||
await new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-orbital-daylight');
|
||||
});
|
||||
return (
|
||||
engine.camera.height() > 1_500_000 &&
|
||||
engine.host.container.hasAttribute('data-terrain-daylight')
|
||||
);
|
||||
}),
|
||||
'the orbital frame keeps daylight on the inspected globe',
|
||||
);
|
||||
await page.$eval('[data-stack-id="osm"]', (el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === 'osm' &&
|
||||
!window.__godsEyeView.engine.host.container.hasAttribute(
|
||||
'data-terrain-daylight',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS Cesium default, both switches, same document, warm engine identity, parked canvas, no startup splash, share pose/map/layers, Back/Forward, remembered engine, blocked preference storage',
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
* them real types AMBIENTLY (nothing is ever tracked, 3D stays off), while
|
||||
* honoring the politeness bounds.
|
||||
*
|
||||
* Fetch shim (installed before app code, same pattern as qa-sprites-b5.mjs):
|
||||
* Fetch shim (installed before app code, same pattern as qa-aircraft-sprites.mjs):
|
||||
* - /api/flights → 12 straight-flying planes, category 0
|
||||
* - /api/adsbdb/type/:hex → varied REAL type codes (C172, B744, DH8D, H60,
|
||||
* F16, GLID, B77W, A320, B738, PC12, R44) + one
|
||||
@@ -28,15 +28,15 @@
|
||||
* E3 requests : the sweep requested all 12 hexes, each exactly once
|
||||
* E4 diversity : ≥5 distinct glyph data-URIs displayed ambiently after
|
||||
* enrichment (expected: 8)
|
||||
* E5 glyphs : every billboard's image === aircraftIcon(classify(typeCode))
|
||||
* E5 glyphs : exact classified silhouette at its registered 64/192 px raster
|
||||
* E6 scales : every billboard's scale === CLASS_SCALE_2D[klass]
|
||||
* E7 bounds : ≤4 concurrent requests, ≥185 ms between dispatches (≤5/s
|
||||
* drip), total ≤300 (budget ceiling)
|
||||
* E8 next poll : another poll neither reverts glyphs nor re-requests hexes
|
||||
* E10 exhaust : the ROLLING ambient budget exhausts — new on-screen planes
|
||||
* stop being requested once the bucket is empty (shrunk via
|
||||
* the __GEV_ENRICH_AMBIENT_QA seam; a real 5-min window can't
|
||||
* be waited out headlessly)
|
||||
* stop at the real 300-token initial ceiling; the QA seam
|
||||
* shortens only the refill window (a real 5-min window
|
||||
* cannot be waited out headlessly)
|
||||
* E11 resume : after a refill window passes, ambient enrichment RESUMES
|
||||
* for the still-unrequested planes (the 2026-07-03 field bug:
|
||||
* the old one-shot session cap never refilled, so hours-long
|
||||
@@ -57,7 +57,7 @@ import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../src/data/aircraftIcons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args (same shape as qa-sprites-b5.mjs)
|
||||
// Args (same shape as qa-aircraft-sprites.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
const argv = process.argv.slice(2);
|
||||
const getFlag = (name) => argv.includes(name);
|
||||
@@ -66,9 +66,15 @@ const getOpt = (name, dflt) => {
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const BASE_URL = getOpt('--url', 'http://localhost:4173');
|
||||
// --engine=cesium|navara: both engines read the layer through the QA probe
|
||||
// (window.__godsEyeView.layers.drawn, the dev server's) and move the engine
|
||||
// camera.
|
||||
const ENGINE = (argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/enrich-ambient');
|
||||
const SHOT_DIR = path.resolve(ENGINE === 'cesium' ? 'qa-shots/enrich-ambient' : `qa-shots/enrich-ambient-${ENGINE}`);
|
||||
|
||||
// Client-side bounds under test (flights.js constants):
|
||||
const MAX_INFLIGHT = 4; // ENRICH_MAX_INFLIGHT
|
||||
@@ -76,13 +82,13 @@ const DISPATCH_GAP_MS = 200; // ENRICH_DISPATCH_GAP_MS (≤5 req/s)
|
||||
const GAP_TOLERANCE_MS = 15; // clock-quantization allowance on the gap check
|
||||
const SESSION_CAP = 300; // ENRICH_AMBIENT_BUDGET_CEIL (rolling-bucket ceiling)
|
||||
|
||||
// Rolling-budget QA seam (window.__GEV_ENRICH_AMBIENT_QA in flights.js):
|
||||
// shrunk so the exhaust→refill cycle is observable headlessly. windowMs starts
|
||||
// Rolling-budget QA seam: exercise the real initial ceiling and shorten
|
||||
// only the refill window so the exhaust→refill cycle is observable. windowMs starts
|
||||
// effectively-infinite (1 h) so E10 exhausts deterministically (no refill can
|
||||
// land mid-phase); E11 then shortens it in-page so refill windows have
|
||||
// "passed" and the bucket refills (clamped at ceil). ceil=20 leaves 8 tokens
|
||||
// after the 12 baseline planes — E10's batch exhausts those 8 and stalls.
|
||||
const BUDGET_QA = { ceil: 20, refillTokens: 6, windowMs: 3600000 };
|
||||
// "passed" and the bucket refills (clamped at ceil). The initial production
|
||||
// ceiling of 300 is exercised without assuming the override resets state.
|
||||
const BUDGET_QA = { ceil: SESSION_CAP, refillTokens: 6, windowMs: 3600000 };
|
||||
const RESUME_WINDOW_MS = 1000; // E11 swaps windowMs to this to unlock refills
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
@@ -160,6 +166,9 @@ const expected = new Map(Object.entries(TYPES).map(([hex, tc]) => [
|
||||
hex, classifyAircraft(tc ? { typeCode: tc } : { category: 0 }),
|
||||
]));
|
||||
const AIRLINER_ICON = aircraftIcon(classifyAircraft({ category: 0 })); // pre-enrichment default
|
||||
// Adaptive raster resolution preserves the classified silhouette. Compare
|
||||
// exact registered assets at either supported resolution, never another class.
|
||||
const matchesClassIcon = (image, klass) => image === aircraftIcon(klass) || image === aircraftIcon(klass, 192);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
@@ -185,7 +194,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -208,6 +217,7 @@ async function main() {
|
||||
|
||||
// ---- Fetch shim: synthetic fleet + instrumented, holdable adsbdb -------
|
||||
await page.evaluateOnNewDocument((spec) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.__ENR = spec;
|
||||
window.__ENR.epochMs = Date.now();
|
||||
// Rolling-budget QA seam — must exist BEFORE flights.js initializes so
|
||||
@@ -289,30 +299,25 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000, polling: 200 }
|
||||
() => window.__godsEyeView && window.__godsEyeView.engine && window.__godsEyeView.dataManager && window.__godsEyeView.layers,
|
||||
// A cold dev server compiles the Navara chunks on first load.
|
||||
{ timeout: 180000, polling: 200 }
|
||||
);
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.initialRestorePromise);
|
||||
await page.waitForFunction(() => document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 180000 });
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager.orbitController.stop());
|
||||
console.log(' App globals ready.');
|
||||
|
||||
// ---- In-page billboard probe (same walk as qa-sprites-b5.mjs) ----------
|
||||
// ---- In-page billboard probe (same as qa-aircraft-sprites.mjs) ---------------
|
||||
// What the flights layer draws, through the engine-neutral probe
|
||||
// (layers.drawn: the layer's own sprite handles) on either engine. The
|
||||
// layer owns no Cesium billboards any more: the contract's batched
|
||||
// billboards carry atlas keys, not the glyph data-URIs.
|
||||
await page.evaluate(() => {
|
||||
window.__collectBillboards = function () {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
const walk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p;
|
||||
try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
out.push({ id: p.id, image: p.image, scale: p.scale, show: p.show });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
return out;
|
||||
return (window.__godsEyeView.layers.drawn('flights') ?? [])
|
||||
.filter((row) => !row.trackedModel)
|
||||
.map(({ id, image, scale, show }) => ({ id, image, scale, show }));
|
||||
};
|
||||
});
|
||||
|
||||
@@ -321,12 +326,12 @@ async function main() {
|
||||
// framing the fleet when the layer polls — otherwise the frustum test
|
||||
// (correctly) skips off-screen planes.
|
||||
const centerLon = ROW_LON0 + ((SPEC.planes.length - 1) / 2) * ROW_STEP_DEG;
|
||||
await page.evaluate(({ lat, lon, height }) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const C3 = v.camera.position.constructor;
|
||||
v.camera.setView({
|
||||
destination: C3.fromDegrees(lon, lat, height),
|
||||
await page.evaluate(async ({ lat, lon, height }) => {
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight(); // the boot fly-to-Austin otherwise stomps setView
|
||||
const geo = await import('/src/geo/index.js');
|
||||
camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
}, { lat: ROW_LAT, lon: centerLon, height: 20000 });
|
||||
@@ -334,8 +339,9 @@ async function main() {
|
||||
console.log('Priming straight-flight history through the render delay (30 s)...');
|
||||
const primed = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
window.__ENR.timeOffsetSec = -32;
|
||||
dm.setLayerParams('flights', { models3d: false }); // ambient budget excludes model-priority enrichment
|
||||
await dm.setEnabled('flights', true);
|
||||
const fl = dm.layers.get('flights').module;
|
||||
for (const off of [-24, -16, -8, 0]) {
|
||||
@@ -344,11 +350,11 @@ async function main() {
|
||||
}
|
||||
window.__ENR.timeOffsetSec = 0;
|
||||
window.__ENR_DRIVER = setInterval(() => { fl.update(v); }, 15000);
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length };
|
||||
return { count: fl.getStats().count, starts: window.__ENRICH_LOG.starts.length, models3d: fl.getParams().models3d };
|
||||
});
|
||||
console.log(` flights count=${primed.count} | adsbdb requests started (held): ${primed.starts}`);
|
||||
record('E1 ingest: all synthetic category-0 planes in the flights layer',
|
||||
primed.count === SPEC.planes.length, `count=${primed.count}/${SPEC.planes.length}`);
|
||||
primed.count === SPEC.planes.length && primed.models3d === false, `count=${primed.count}/${SPEC.planes.length}, ambient-only models3d=${primed.models3d}`);
|
||||
if (primed.count === 0) { finish(); return; }
|
||||
|
||||
// ========================================================================
|
||||
@@ -414,12 +420,11 @@ async function main() {
|
||||
const scaleBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterById.get(hex);
|
||||
const wantImg = aircraftIcon(klass);
|
||||
const wantScale = CLASS_SCALE_2D[klass] || 1;
|
||||
if (!bb || bb.image !== wantImg) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (!bb || !matchesClassIcon(bb.image, klass)) glyphBad.push(`${hex}(${klass})${bb ? ':wrong-image' : ':missing'}`);
|
||||
if (bb && Math.abs(bb.scale - wantScale) > 1e-9) scaleBad.push(`${hex}: ${bb.scale} != ${wantScale}`);
|
||||
}
|
||||
record('E5 glyphs: every billboard matches aircraftIcon(classify(typeCode))',
|
||||
record('E5 glyphs: every billboard matches its classified 64/192 px asset',
|
||||
glyphBad.length === 0, glyphBad.length ? glyphBad.join(' ') : `${expected.size} matched (incl. the found:false miss staying airliner)`);
|
||||
record('E6 scales: every billboard has its per-class CLASS_SCALE_2D',
|
||||
scaleBad.length === 0, scaleBad.length ? scaleBad.join(' ') : 'all scales exact (composes with scaleByDistance)');
|
||||
@@ -456,7 +461,7 @@ async function main() {
|
||||
console.log('\nE8 — next poll after enrichment');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
await dm.layers.get('flights').module.update(v);
|
||||
});
|
||||
await sleep(500);
|
||||
@@ -468,7 +473,7 @@ async function main() {
|
||||
const pollBad = [];
|
||||
for (const [hex, klass] of expected) {
|
||||
const bb = afterPollById.get(hex);
|
||||
if (!bb || bb.image !== aircraftIcon(klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
if (!bb || !matchesClassIcon(bb.image, klass)) pollBad.push(`${hex}(${klass})${bb ? ':reverted' : ':missing'}`);
|
||||
else if (Math.abs(bb.scale - (CLASS_SCALE_2D[klass] || 1)) > 1e-9) pollBad.push(`${hex}:scale-reverted`);
|
||||
}
|
||||
const noReRequests = afterPoll.starts === Object.keys(TYPES).length;
|
||||
@@ -480,20 +485,19 @@ async function main() {
|
||||
// E10 — rolling-budget exhaustion (2026-07-03 field bug, red/green): the
|
||||
// old ONE-SHOT session cap burned out and never refilled, so every new
|
||||
// region after the first polls showed airliner monoculture. With the seam
|
||||
// ceil=20 and 12 tokens spent above, only 8 remain — a batch of 26 fresh
|
||||
// on-screen planes must stall at exactly 8 new requests, and further polls
|
||||
// initial ceiling=300 and 12 tokens spent above, 288 remain. More than
|
||||
// 300 total candidates must stall at exactly 300 requests; further polls
|
||||
// must enqueue NOTHING while the bucket is empty (windowMs is 1 h here, so
|
||||
// no refill can land mid-phase). Pre-fix code fails this phase: there is
|
||||
// no rolling bucket to exhaust (the seam is unread and the 300 cap simply
|
||||
// admits the whole batch).
|
||||
// no refill can land mid-phase). E10 verifies the initial limit; E11
|
||||
// distinguishes a rolling refill from the old one-shot session cap.
|
||||
// ========================================================================
|
||||
console.log('\nE10 — rolling budget exhausts (batch of 26 new planes, 8 tokens left)');
|
||||
const BATCH_COUNT = 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => `ab00${(i + 1).toString(16).padStart(2, '0')}`);
|
||||
console.log('\nE10 — rolling budget exhausts (real 300-request initial bucket)');
|
||||
const BATCH_COUNT = BUDGET_QA.ceil + 26;
|
||||
const batchHexes = Array.from({ length: BATCH_COUNT }, (_, i) => (0xab0000 + i + 1).toString(16).padStart(6, '0'));
|
||||
const startsBeforeBatch = await page.evaluate(() => window.__ENRICH_LOG.starts.length);
|
||||
await page.evaluate(async ({ hexes, rowLat, rowLon0, stepDeg }) => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const v = window.__godsEyeView.viewer ?? null;
|
||||
// Two fresh rows just north of the original one — still inside the
|
||||
// top-down 20 km frame, so the sweep's frustum test keeps them.
|
||||
// STATIONARY (speed 0): moving planes drift north out of the frustum at
|
||||
@@ -506,33 +510,47 @@ async function main() {
|
||||
lat0: rowLat + (i < 13 ? 0.02 : 0.04),
|
||||
courseDeg: 0,
|
||||
speedMps: 0,
|
||||
altM: 2600 + i * 15,
|
||||
altM: 2600 + (i % 26) * 15,
|
||||
});
|
||||
});
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = window.__godsEyeView.engine;
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(rowLon0 + 5.5 * stepDeg, rowLat, 20000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
for (let frame = 0; frame < 30; frame++) {
|
||||
engine.render.request('qa-enrich-budget');
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
const fl = dm.layers.get('flights').module;
|
||||
await fl.update(v); // sweep enqueues the last 8 tokens
|
||||
await fl.update(v); // bucket empty — must enqueue nothing more
|
||||
await fl.update(v); // sweep enqueues up to 150 ambient jobs
|
||||
await fl.update(v); // spend the remaining initial tokens; later polls must stall
|
||||
}, { hexes: batchHexes, rowLat: ROW_LAT, rowLon0: ROW_LON0, stepDeg: ROW_STEP_DEG });
|
||||
// Wait for the drip to drain whatever was enqueued, then settle.
|
||||
await page.waitForFunction(
|
||||
(min) => window.__ENRICH_LOG.starts.length >= min && window.__ENRICH_LOG.inflight === 0,
|
||||
{ timeout: 20000, polling: 100 }, startsBeforeBatch + (BUDGET_QA.ceil - startsBeforeBatch)
|
||||
{ timeout: 90000, polling: 100 }, BUDGET_QA.ceil
|
||||
).catch(() => { /* asserted below */ });
|
||||
await sleep(1500); // any illegal post-exhaustion enqueue would dispatch within ~200 ms
|
||||
// One more poll while exhausted — still nothing new.
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||
});
|
||||
await sleep(1000);
|
||||
const exhausted = await page.evaluate(() => ({
|
||||
starts: window.__ENRICH_LOG.starts.length,
|
||||
inflight: window.__ENRICH_LOG.inflight,
|
||||
sourceCount: window.__godsEyeView.dataManager.layers.get('flights').module.getStats().count,
|
||||
fixtureCount: window.__ENR.planes.length,
|
||||
cameraHeightM: window.__godsEyeView.engine.camera.pose().cartographic.height,
|
||||
}));
|
||||
record(`E10 exhaust: requests stall at the budget ceiling (${BUDGET_QA.ceil}) with unrequested planes on-screen`,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0,
|
||||
exhausted.starts === BUDGET_QA.ceil && exhausted.inflight === 0 && exhausted.sourceCount === startsBeforeBatch + BATCH_COUNT,
|
||||
`starts=${exhausted.starts} (want ${BUDGET_QA.ceil}: ${startsBeforeBatch} baseline + ${BUDGET_QA.ceil - startsBeforeBatch} tokens; `
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}`);
|
||||
+ `${BATCH_COUNT - (BUDGET_QA.ceil - startsBeforeBatch)} planes left waiting) inflight=${exhausted.inflight}, source=${exhausted.sourceCount}/${exhausted.fixtureCount}, camera=${exhausted.cameraHeightM.toFixed(1)}m`);
|
||||
|
||||
// ========================================================================
|
||||
// E11 — refill window passes → enrichment RESUMES. Shorten windowMs so the
|
||||
@@ -544,7 +562,7 @@ async function main() {
|
||||
await sleep(RESUME_WINDOW_MS + 200); // a full (shortened) refill window elapses
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer);
|
||||
await dm.layers.get('flights').module.update(window.__godsEyeView.viewer ?? null);
|
||||
});
|
||||
const expectedTotal = startsBeforeBatch + BATCH_COUNT; // every batch plane eventually requested
|
||||
const resumed = await page.waitForFunction(
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installAlprFixture, cameras } from './fixtures/layers/alpr.mjs';
|
||||
import { firmsFixture, FIRMS_VALID_COUNT } from './fixtures/layers/firms.mjs';
|
||||
import {
|
||||
clickEmptyGround,
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForCameraAtRest,
|
||||
} from './qa-strict-harness.mjs';
|
||||
const url = localPreview(process.argv[2]),
|
||||
fire = process.argv.includes('--fires'),
|
||||
id = fire ? 'local-firms' : 'alpr-cameras';
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&' +
|
||||
(fire
|
||||
? 'lat=37.77&lon=-122.42&alt=100000'
|
||||
: 'lat=30.262&lon=-97.7431&alt=5500') +
|
||||
'&pitch=-90&heading=0&map=sentinel2&l=';
|
||||
// Records the fixtures produce: the valid FIRMS rows; the ALPR cameras the
|
||||
// fixture tiles carry around Austin.
|
||||
const COUNT = fire ? FIRMS_VALID_COUNT : cameras.length;
|
||||
const strict = createStrict({
|
||||
origin: url.origin,
|
||||
layers: [id],
|
||||
// installAlprFixture answers the camera tiles.
|
||||
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const p = await b.newPage();
|
||||
await p.setViewport({ width: 1280, height: 900 });
|
||||
const errors = [];
|
||||
p.on('pageerror', (e) => errors.push(e.message));
|
||||
await installAlprFixture(p);
|
||||
await p.evaluateOnNewDocument((data) => {
|
||||
window.qaFirmsReads = 0;
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, opt) => {
|
||||
if (
|
||||
new URL(input.url ?? input, location.href).pathname === '/api/firms'
|
||||
) {
|
||||
window.qaFirmsReads++;
|
||||
return Promise.resolve(Response.json(data));
|
||||
}
|
||||
return fetch(input, opt);
|
||||
};
|
||||
}, firmsFixture());
|
||||
await p.goto(url.href);
|
||||
await p.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView?.engine?.kind === 'navara' &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
);
|
||||
await assertKeylessPreview(p);
|
||||
await p.evaluate(async (id) => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
await styleManager.initialRestorePromise;
|
||||
window.qaGround = dataManager.layers.get(id).module;
|
||||
await dataManager.setEnabled(id, true, { origin: 'user' });
|
||||
}, id);
|
||||
await p.waitForFunction(() => window.qaGround?.getStats().count > 0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// A pickable target in the middle of the view: the camera the engine's
|
||||
// own picker returns, or the hottest fire.
|
||||
const findTarget = () =>
|
||||
p
|
||||
.waitForFunction(
|
||||
(fire) => {
|
||||
const a = window.__godsEyeView,
|
||||
m = window.qaGround;
|
||||
const points = fire
|
||||
? m
|
||||
.getDetectableObjects({ maxCount: 1000 })
|
||||
.filter((v) => v.id === m.getAnalystRecords(1)[0]?.id)
|
||||
: [...window.__gevContextStore.entities.values()]
|
||||
.filter((v) => v.layerId === 'alpr-cameras')
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
position:
|
||||
v.entity.gevAlprCanvasPosition ||
|
||||
v.entity.gevDisplayPosition?.(),
|
||||
}));
|
||||
for (const row of points) {
|
||||
if (!row.position) continue;
|
||||
const s = a.engine.projector.worldToWindow(row.position);
|
||||
if (!s || s.x < 430 || s.x > 850 || s.y < 220 || s.y > 600)
|
||||
continue;
|
||||
const canvas = a.engine.host.canvas;
|
||||
const box = canvas.getBoundingClientRect();
|
||||
if (
|
||||
!canvas.contains(
|
||||
document.elementFromPoint(box.left + s.x, box.top + s.y),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const picked = a.engine.picker.pick(s);
|
||||
if (fire || picked?.id === row.id || picked?.id?.id === row.id)
|
||||
return { x: box.left + s.x, y: box.top + s.y, id: row.id };
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
fire,
|
||||
)
|
||||
.then((handle) => handle.jsonValue());
|
||||
const selected = (id) =>
|
||||
p.waitForFunction(
|
||||
(id, fire) =>
|
||||
fire
|
||||
? window.qaGround.getSelectedInfo()?.frp === 999
|
||||
: window.__gevContextStore?.selectedEntityId === id,
|
||||
{ timeout: 15000 },
|
||||
id,
|
||||
fire,
|
||||
);
|
||||
// A click on empty ground (nothing picked there, nothing covering it).
|
||||
const clickEmpty = async () => {
|
||||
await clickEmptyGround(p, {
|
||||
left: 440,
|
||||
top: 240,
|
||||
right: 840,
|
||||
bottom: 600,
|
||||
step: 9,
|
||||
radius: 6,
|
||||
});
|
||||
await p.waitForFunction(
|
||||
(fire) =>
|
||||
fire
|
||||
? !window.qaGround.getSelectedInfo()
|
||||
: !window.__gevContextStore?.selectedEntityId,
|
||||
{ timeout: 15000 },
|
||||
fire,
|
||||
);
|
||||
};
|
||||
const xy = await findTarget();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await p.mouse.click(xy.x, xy.y);
|
||||
await selected(xy.id);
|
||||
await strict.passed(
|
||||
p,
|
||||
id,
|
||||
fire
|
||||
? 'a click on the hottest fire selects it'
|
||||
: 'a click on a camera selects it',
|
||||
xy,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 1600));
|
||||
// The selection flies the camera to the record (1.9 s, longer on a busy
|
||||
// machine); a switch before it lands opens the other engine on a view
|
||||
// between the two, where the record can be off screen.
|
||||
await waitForCameraAtRest(p);
|
||||
const expectedId = fire
|
||||
? await p.evaluate(() => window.qaGround.getSelectedInfo().id)
|
||||
: xy.id;
|
||||
const before = await p.evaluate(() => ({
|
||||
stamp: window.qaGround.getStats().lastUpdate,
|
||||
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||
reads: window.qaFirmsReads,
|
||||
}));
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.switchTo(p, kind);
|
||||
const result = await p.evaluate(
|
||||
(id) => ({
|
||||
same:
|
||||
window.qaGround ===
|
||||
window.__godsEyeView.dataManager.layers.get(id).module,
|
||||
selected: window.__gevContextStore.selectedEntityId,
|
||||
record: window.qaGround.getSelectedInfo?.()?.id,
|
||||
stamp: window.qaGround.getStats().lastUpdate,
|
||||
records: window.qaGround.getAnalystRecords(1000).map((v) => v.id),
|
||||
reads: window.qaFirmsReads,
|
||||
count: window.qaGround.getStats().count,
|
||||
}),
|
||||
id,
|
||||
);
|
||||
console.log(kind, id, result.selected, result.count, result.records.length);
|
||||
assert.ok(result.same && result.count);
|
||||
assert.equal(result.selected, expectedId);
|
||||
if (fire) assert.equal(result.record, expectedId);
|
||||
assert.equal(result.reads, before.reads);
|
||||
assert.deepEqual(result.records, before.records);
|
||||
assert.equal(result.stamp, before.stamp);
|
||||
await strict.check(
|
||||
p,
|
||||
id,
|
||||
`same model, ${COUNT} records, the clicked record still selected, no source read, same receipt time`,
|
||||
result.count === COUNT,
|
||||
{ count: result.count },
|
||||
);
|
||||
if (kind === 'cesium') {
|
||||
// Click-picking on Cesium: clear the selection with a click on empty
|
||||
// ground, then click the same record again.
|
||||
await clickEmpty();
|
||||
const again = await findTarget();
|
||||
assert.equal(again.id, xy.id);
|
||||
await p.mouse.click(again.x, again.y);
|
||||
await selected(again.id);
|
||||
await strict.passed(
|
||||
p,
|
||||
id,
|
||||
fire
|
||||
? 'a click on empty ground clears the fire; a click on it selects it again'
|
||||
: 'a click on empty ground clears the camera; a click on it selects it again',
|
||||
again,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS retained source, stable clicked record on each planet');
|
||||
});
|
||||
+73
-16
@@ -30,22 +30,36 @@
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import {
|
||||
isExpectedPreviewSetupError,
|
||||
redact,
|
||||
} from './qa-browserEvidence.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4420');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4420'));
|
||||
const ENGINE = getOpt('--engine', appUrl.searchParams.get('engine') || 'cesium');
|
||||
if (!['cesium', 'navara'].includes(ENGINE)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', ENGINE);
|
||||
const APP_URL = appUrl.href;
|
||||
const HEADFUL = argv.includes('--headful');
|
||||
const FIXTURES = argv.includes('--fixtures');
|
||||
const PREVIEW = argv.includes('--preview');
|
||||
const ANGLE = getOpt('--angle', 'swiftshader');
|
||||
const OUT = getOpt('--out', null);
|
||||
const SHOTS_DIR = OUT ? path.resolve(OUT).replace(/\.json$/, '') + '-shots' : path.join(REPO_ROOT, 'qa-shots');
|
||||
if (!['metal', 'swiftshader'].includes(ANGLE)) throw new Error('Unknown ANGLE backend');
|
||||
const runtimeErrors = [];
|
||||
const expectedSourceErrors = [];
|
||||
|
||||
const CHROME_EXECUTABLE_CANDIDATES = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
@@ -157,9 +171,11 @@ async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {
|
||||
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
() => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const actualEngine = await page.evaluate(() => window.__godsEyeView.engine.kind);
|
||||
if (actualEngine !== ENGINE) throw new Error('Requested FIRMS engine did not load');
|
||||
await sleep(5000);
|
||||
await page.keyboard.press('Escape');
|
||||
return page.evaluate(async (tS) => {
|
||||
@@ -178,8 +194,15 @@ async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
||||
|
||||
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
||||
async function setView(page, lon, lat, height) {
|
||||
await page.evaluate((lo, la, h) => {
|
||||
await page.evaluate(async (lo, la, h) => {
|
||||
const gev = window.__godsEyeView;
|
||||
if (!gev.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
gev.engine.camera.cancelFlight();
|
||||
gev.engine.camera.setView({destination:geo.cartesian3.fromDegrees(lo,la,h),orientation:{heading:0,pitch:-Math.PI/2,roll:0}});
|
||||
gev.engine.render.request('firms-legacy-view');
|
||||
return;
|
||||
}
|
||||
const ell = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||
@@ -251,7 +274,7 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', `--use-angle=${ANGLE}`,
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1440,900',
|
||||
@@ -265,6 +288,23 @@ async function main() {
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
let interceptMode = FIXTURES ? 'fixture' : 'live';
|
||||
page.on('pageerror', error => runtimeErrors.push(redact(error.message)));
|
||||
page.on('error', error => runtimeErrors.push(redact(error.message)));
|
||||
page.on('console', message => {
|
||||
if (message.type() !== 'error') return;
|
||||
if (isExpectedPreviewSetupError(message, { preview: PREVIEW, baseUrl: APP_URL })) {
|
||||
expectedSourceErrors.push('/api/setup/status: 404');
|
||||
return;
|
||||
}
|
||||
let expected = false;
|
||||
try {
|
||||
const url = new URL(message.location()?.url);
|
||||
expected = interceptMode === 'keyless' && url.origin === new URL(APP_URL).origin
|
||||
&& url.pathname === '/api/firms' && /^Failed to load resource:.*\b503\b/.test(message.text());
|
||||
} catch { /* an unknown location is never waived */ }
|
||||
if (expected) expectedSourceErrors.push('/api/firms: intentional keyless fixture503');
|
||||
else runtimeErrors.push(redact(message.text()));
|
||||
});
|
||||
const stableStalePayload = stalePayload();
|
||||
page.on('request', (req) => {
|
||||
const url = req.url();
|
||||
@@ -386,17 +426,22 @@ async function main() {
|
||||
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
||||
if (!target?.position) return null;
|
||||
|
||||
const sentinel = gev.viewer.entities.add({
|
||||
const native = Boolean(gev.viewer);
|
||||
const sentinel = native ? gev.viewer.entities.add({
|
||||
id: 'qa-firms-prior-camera-owner',
|
||||
position: target.position,
|
||||
});
|
||||
gev.viewer.trackedEntity = sentinel;
|
||||
}) : gev.engine.camera.follow({position:()=>target.position,viewFrom:{x:0,y:-5000,z:8000},
|
||||
label:{id:'qa-firms-prior-camera-owner',model:{title:'QA prior camera owner'},position:()=>target.position}});
|
||||
if (native) gev.viewer.trackedEntity = sentinel;
|
||||
|
||||
const camera = gev.viewer.camera;
|
||||
const originalFly = camera.flyToBoundingSphere;
|
||||
const camera = native ? gev.viewer.camera : gev.engine.camera;
|
||||
const flightMethod = native ? 'flyToBoundingSphere' : 'flyToSphere';
|
||||
const originalFly = camera[flightMethod];
|
||||
const proof = {
|
||||
sentinel,
|
||||
originalFly,
|
||||
flightMethod,
|
||||
native,
|
||||
flightCount: 0,
|
||||
trackedAtFlight: 'not-called',
|
||||
request: null,
|
||||
@@ -409,9 +454,9 @@ async function main() {
|
||||
};
|
||||
};
|
||||
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
||||
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
||||
camera[flightMethod] = function qaFirmsObservedFlight(...args) {
|
||||
proof.flightCount += 1;
|
||||
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
||||
proof.trackedAtFlight = native ? gev.viewer.trackedEntity?.id || null : gev.engine.camera.following()?.label?.id || null;
|
||||
return originalFly.apply(this, args);
|
||||
};
|
||||
window.__qaFirmsActionProof = proof;
|
||||
@@ -433,7 +478,7 @@ async function main() {
|
||||
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const settled = await page.evaluate(() => {
|
||||
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
||||
window.__godsEyeView?.engine?.render?.request?.('firms-legacy');
|
||||
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
||||
});
|
||||
if (settled) break;
|
||||
@@ -451,7 +496,7 @@ async function main() {
|
||||
const result = {
|
||||
flightCount: state?.flightCount || 0,
|
||||
trackedAtFlight: state?.trackedAtFlight,
|
||||
trackingReleased: gev.viewer.trackedEntity == null,
|
||||
trackingReleased: gev.viewer ? gev.viewer.trackedEntity == null : gev.engine.camera.following() == null,
|
||||
generationBefore: state?.generationBefore,
|
||||
generationAfter: gev.styleManager._navigationGeneration,
|
||||
request: state?.request || null,
|
||||
@@ -461,9 +506,12 @@ async function main() {
|
||||
).length,
|
||||
};
|
||||
if (state) {
|
||||
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
||||
(state.native ? gev.viewer.camera : gev.engine.camera)[state.flightMethod] = state.originalFly;
|
||||
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
||||
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
||||
if (state.sentinel) {
|
||||
if (state.native) gev.viewer.entities.remove(state.sentinel);
|
||||
else state.sentinel.stop();
|
||||
}
|
||||
}
|
||||
delete window.__qaFirmsActionProof;
|
||||
return result;
|
||||
@@ -513,12 +561,21 @@ async function main() {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
record('RUNTIME: no unexpected browser errors', runtimeErrors.length === 0,
|
||||
runtimeErrors.length ? JSON.stringify(runtimeErrors) : `expected source errors=${expectedSourceErrors.length}`);
|
||||
if (runtimeErrors.length) exitCode = 1;
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
if (OUT) {
|
||||
const file = path.resolve(OUT);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ pass:exitCode === 0 && fail === 0, fixture:FIXTURES, angle:ANGLE, engine:ENGINE,
|
||||
shots:SHOTS_DIR, results, browserErrors:runtimeErrors, expectedSourceErrors }, null, 2) + '\n');
|
||||
}
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
|
||||
+46
-11
@@ -48,7 +48,7 @@ const SITE = {
|
||||
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
||||
};
|
||||
const ICAO = 'aa0f01';
|
||||
const OUT_DIR = 'qa-shots/floorhold';
|
||||
const OUT_DIR = new URL(APP_URL).searchParams.get('engine') === 'navara' ? 'qa-shots/floorhold/navara' : 'qa-shots/floorhold';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const results = [];
|
||||
@@ -107,7 +107,7 @@ await page.evaluateOnNewDocument((site, icao) => {
|
||||
|
||||
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
await page.waitForFunction(() => window.__godsEyeView?.layers && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 150000 });
|
||||
await sleep(12000); // let the boot fly-to settle before pinning
|
||||
await page.evaluate(() => {
|
||||
@@ -116,8 +116,19 @@ await page.evaluate(() => {
|
||||
await sleep(600); // let the launcher dismissal finish before taking evidence
|
||||
|
||||
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
||||
const pin = () => page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const pin = () => page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
||||
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
||||
});
|
||||
app.engine.camera.moveEnd.raiseEvent();
|
||||
return;
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({
|
||||
@@ -142,21 +153,43 @@ record('the billboard floor test is explicitly in 2D aircraft mode', billboardMo
|
||||
|
||||
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
||||
const measure = () => page.evaluate(async (icao) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = geo.cartographic.fromCartesian(bb.position);
|
||||
let mesh = null;
|
||||
for (let attempt = 0; attempt < 10 && mesh == null; attempt++) {
|
||||
app.engine.render.request('floor-hold');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const height = app.engine.surface.sampleHeight(carto);
|
||||
if (Number.isFinite(height)) mesh = height;
|
||||
else await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
}
|
||||
return { spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
||||
lat: +(carto.latitude * 180 / Math.PI).toFixed(5), lon: +(carto.longitude * 180 / Math.PI).toFixed(5), terrain: { ...window.__TERRAIN } };
|
||||
}
|
||||
const v = app.viewer;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const sprites = []; const models = []; let bb = null;
|
||||
// The contact as the layer draws it (the probe's drawn rows: the layer owns
|
||||
// no Cesium billboard of its own any more; the engine's batched billboards
|
||||
// keep a hidden spare's old id, so an id match there can be stale).
|
||||
const bb = app.layers.drawn('flights').find((row) => row.id === icao && !row.trackedModel);
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
// Every billboard and model in the scene (the engine's mover batches
|
||||
// included) stays out of the mesh probes.
|
||||
const sprites = []; const models = [];
|
||||
const walk = (coll) => {
|
||||
for (let i = 0; i < coll.length; i++) {
|
||||
let p; try { p = coll.get(i); } catch { continue; }
|
||||
if (!p) continue;
|
||||
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
||||
sprites.push(p); if (p.id === icao) bb = p;
|
||||
} else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
||||
if (p.image !== undefined && p.alignedAxis !== undefined) sprites.push(p);
|
||||
else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
||||
}
|
||||
};
|
||||
try { walk(v.scene.primitives); } catch { /* mid-teardown */ }
|
||||
if (!bb?.position) return { error: 'contact billboard not found' };
|
||||
const carto = C.fromCartesian(bb.position);
|
||||
const lat = carto.latitude * 180 / Math.PI;
|
||||
const lon = carto.longitude * 180 / Math.PI;
|
||||
@@ -201,7 +234,9 @@ async function demTruth(lat, lon) {
|
||||
if (truth.has(key)) return truth.get(key);
|
||||
let h = null;
|
||||
try {
|
||||
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
||||
if (Number.isFinite(v)) h = v;
|
||||
|
||||
+93
-22
@@ -10,7 +10,12 @@ import fs from 'node:fs';
|
||||
|
||||
// QA_BASE_URL matches the sibling harnesses (qa-height-datum / qa-cctv-v2) so a
|
||||
// secondary checkout can verify against its own dev server instead of the default :4173.
|
||||
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
// --engine=cesium|navara: on Navara the camera, the mesh probes and
|
||||
// the sprite census go through the engine and the QA probe.
|
||||
const ENGINE = (process.argv.find((arg) => arg.startsWith('--engine=')) ?? '--engine=cesium').slice(9);
|
||||
const APP_URL =
|
||||
ENGINE === 'cesium' ? BASE_URL : `${BASE_URL.replace(/\/$/, '')}/?engine=${ENGINE}`;
|
||||
// CLI: --lat --lon --floor-min --floor-max (defaults: Austin airport)
|
||||
const argv = Object.fromEntries(process.argv.slice(2).map((a) => a.split('=')).filter((x) => x.length === 2).map(([k, v]) => [k.replace(/^--/, ''), Number(v)]));
|
||||
const SITE = {
|
||||
@@ -32,13 +37,13 @@ const chrome = [
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new', executablePath: chrome,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
args: ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`, '--enable-webgl', '--ignore-gpu-blocklist', '--no-sandbox'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.viewer, { timeout: 150000 });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager && window.__godsEyeView?.engine, { timeout: 150000 });
|
||||
await sleep(2000);
|
||||
|
||||
// Let the app's boot fly-to fully settle FIRST (it was overriding an early
|
||||
@@ -46,8 +51,16 @@ await sleep(2000);
|
||||
// coarse global tiles at ~25 m and the oracle was garbage).
|
||||
await sleep(12000);
|
||||
await page.evaluate((site) => { window.__QA_SITE = site; }, SITE);
|
||||
const camPin = async () => page.evaluate(() => {
|
||||
const camPin = async () => page.evaluate(async () => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { camera } = window.__godsEyeView.engine;
|
||||
camera.cancelFlight();
|
||||
camera.setView({ destination: geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000) });
|
||||
const c = camera.pose().cartographic;
|
||||
return { lat: +(c.latitude * 180 / Math.PI).toFixed(3), lon: +(c.longitude * 180 / Math.PI).toFixed(3), h: Math.round(c.height) };
|
||||
}
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.cancelFlight();
|
||||
v.camera.setView({ destination: v.scene.globe.ellipsoid.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 3000)) });
|
||||
@@ -63,6 +76,7 @@ await sleep(100000);
|
||||
console.log('camera at probe time:', JSON.stringify(await camPin()));
|
||||
const ready = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
if (!v) return { tilesLoaded: window.__godsEyeView.engine.surface.tilesLoaded() };
|
||||
const prims = v.scene.primitives;
|
||||
for (let i = 0; i < prims.length; i++) {
|
||||
const p = prims.get(i);
|
||||
@@ -73,11 +87,60 @@ const ready = await page.evaluate(() => {
|
||||
console.log('tileset ready:', JSON.stringify(ready));
|
||||
await sleep(8000);
|
||||
|
||||
const report = await page.evaluate(() => {
|
||||
const report = await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
const v = gev.viewer;
|
||||
const layer = gev.dataManager.layers.get('flights')?.module;
|
||||
if (!layer) return { err: 'no flights module' };
|
||||
if (!v) {
|
||||
// The engine path: mesh probes through engine.surface, excluding the
|
||||
// layer's own mover sets; the census from the probe's drawn().
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { engine } = gev;
|
||||
const center = geo.cartesian3.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200);
|
||||
const nearby = layer.getNearby(center, 15000, 60);
|
||||
const exclude = engine.movers.list().map(({ id }) => id).filter((id) => id.startsWith('flights'));
|
||||
// The app's own ground floor (the scene's surface services, not a fresh
|
||||
// import of the module).
|
||||
const { cachedGroundFloor } = gev.surfaceServices.groundFloor;
|
||||
const out = [];
|
||||
// A contact a 3D model draws is measured where the model is (its sprite
|
||||
// is hidden and keeps the pre-floor height by design, T7).
|
||||
const drawnRows = new Map((gev.layers.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||
const drawnAt = (p) => {
|
||||
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||
return model?.show && model.ready && model.position ? model.position : null;
|
||||
};
|
||||
for (const p of nearby) {
|
||||
const modelAt = drawnAt(p);
|
||||
const c = geo.cartographic.fromCartesian(modelAt ?? p.position);
|
||||
const answer = engine.surface.sampleHeightDetailed(
|
||||
geo.cartographic.fromRadians(c.longitude, c.latitude, 0),
|
||||
{ exclude },
|
||||
);
|
||||
const meshH = answer?.source === 'tiles' && Number.isFinite(answer.height) ? answer.height : null;
|
||||
out.push({
|
||||
id: p.id,
|
||||
renderAltM: +c.height.toFixed(1),
|
||||
drawnBy: modelAt ? 'model' : 'sprite',
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||
// Why a contact sits where it does: the probe's own
|
||||
// answer (source, tile size) and the coarse DEM floor (the prior).
|
||||
probe: answer ? { source: answer.source, tileSizeM: answer.tileSizeM ?? null } : null,
|
||||
demFloorM: cachedGroundFloor(c.latitude * 180 / Math.PI, c.longitude * 180 / Math.PI) ?? null,
|
||||
});
|
||||
}
|
||||
let shown = 0, hidden = 0;
|
||||
for (const row of gev.layers.drawn('flights') ?? []) {
|
||||
if (!row.position) continue;
|
||||
const cc = geo.cartographic.fromCartesian(row.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (row.show) shown++; else hidden++;
|
||||
}
|
||||
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
|
||||
}
|
||||
const ell = v.scene.globe.ellipsoid;
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
const center = ell.cartographicToCartesian(C.fromDegrees(window.__QA_SITE.lon, window.__QA_SITE.lat, 200));
|
||||
@@ -101,9 +164,15 @@ const report = await page.evaluate(() => {
|
||||
}
|
||||
};
|
||||
walk(v.scene.primitives);
|
||||
const drawnRows = new Map((gev.layers?.drawn('flights') ?? []).map((row) => [row.id, row]));
|
||||
const drawnAt = (p) => {
|
||||
const model = drawnRows.get(p.icao24 ?? p.id)?.model;
|
||||
return model?.show && model.ready && model.position ? model.position : null;
|
||||
};
|
||||
const out = [];
|
||||
for (const p of nearby) {
|
||||
const c = ell.cartesianToCartographic(p.position);
|
||||
const modelAt = drawnAt(p);
|
||||
const c = ell.cartesianToCartographic(modelAt ?? p.position);
|
||||
const latDeg = c.latitude * 180 / Math.PI, lonDeg = c.longitude * 180 / Math.PI;
|
||||
let meshH = null;
|
||||
try {
|
||||
@@ -113,28 +182,30 @@ const report = await page.evaluate(() => {
|
||||
out.push({
|
||||
id: p.id,
|
||||
renderAltM: +c.height.toFixed(1),
|
||||
drawnBy: modelAt ? 'model' : 'sprite',
|
||||
meshM: meshH != null ? +meshH.toFixed(1) : null,
|
||||
aboveMeshM: meshH != null ? +(c.height - meshH).toFixed(1) : null,
|
||||
// A contact whose sprite hides under a model that is not (yet) drawn.
|
||||
model: drawnRows.get(p.icao24 ?? p.id)?.model
|
||||
? { show: drawnRows.get(p.icao24 ?? p.id).model.show, ready: drawnRows.get(p.icao24 ?? p.id).model.ready }
|
||||
: null,
|
||||
spriteShown: drawnRows.get(p.icao24 ?? p.id)?.show ?? null,
|
||||
// The coarse floor (mesh cell ?? DEM prior) the layer reads here.
|
||||
demFloorM: gev.surfaceServices?.groundFloor.cachedGroundFloor(latDeg, lonDeg) ?? null,
|
||||
});
|
||||
}
|
||||
// Visibility census (round 6): getNearby only returns contacts a sprite or a
|
||||
// 3D model is actually drawing, so also walk the raw collections — a culled
|
||||
// "invisible plane" shows up here.
|
||||
// 3D model is actually drawing, so also count every glyph the layer holds
|
||||
// (the probe's drawn rows) — a culled "invisible plane" shows up here. The
|
||||
// engine's batched billboards keep hidden spares, so they are not counted.
|
||||
let shown = 0, hidden = 0;
|
||||
const censusWalk = (coll) => {
|
||||
const n = coll.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let pr; try { pr = coll.get(i); } catch { continue; }
|
||||
if (!pr) continue;
|
||||
if (typeof pr.length === 'number' && typeof pr.get === 'function') { censusWalk(pr); continue; }
|
||||
if (pr.image === undefined || pr.alignedAxis === undefined) continue;
|
||||
const cc = ell.cartesianToCartographic(pr.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (pr.show) shown++; else hidden++;
|
||||
}
|
||||
};
|
||||
censusWalk(v.scene.primitives);
|
||||
for (const row of gev.layers?.drawn('flights') ?? []) {
|
||||
if (!row.position || row.trackedModel) continue;
|
||||
const cc = ell.cartesianToCartographic(row.position);
|
||||
const la = cc.latitude * 180 / Math.PI, lo = cc.longitude * 180 / Math.PI;
|
||||
if (Math.abs(la - window.__QA_SITE.lat) > 0.08 || Math.abs(lo - window.__QA_SITE.lon) > 0.10) continue;
|
||||
if (row.show) shown++; else hidden++;
|
||||
}
|
||||
return { ausContacts: out.length, contacts: out.slice(0, 16), spritesShown: shown, spritesHidden: hidden };
|
||||
});
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* --headful drops those flags and is the real-GPU visual sign-off path.
|
||||
*/
|
||||
|
||||
import { redact } from './qa-browserEvidence.mjs';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -29,7 +30,13 @@ const getOpt = (name, fallback = null) => {
|
||||
};
|
||||
const hasFlag = (name) => argv.includes(name);
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const appUrl = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
const requestedEngine = argv.find((value) => value.startsWith('--engine='))?.slice(9) || getOpt('--engine');
|
||||
if (requestedEngine) {
|
||||
if (!['cesium', 'navara'].includes(requestedEngine)) throw new Error('Unknown engine');
|
||||
appUrl.searchParams.set('engine', requestedEngine);
|
||||
}
|
||||
const APP_URL = appUrl.href;
|
||||
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
|
||||
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
|
||||
const HEADFUL = hasFlag('--headful');
|
||||
@@ -125,6 +132,10 @@ async function readTileReadiness(page) {
|
||||
const activeStack = controller?.getActiveId?.() || null;
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
return {
|
||||
tilesSettled: !applicable || tileset?.tilesLoaded === true,
|
||||
tileSettleApplicable: applicable,
|
||||
@@ -142,6 +153,10 @@ async function awaitTilesSettled(page, scenario) {
|
||||
// the bootstrap field is retained only as a compatibility fallback.
|
||||
const tileset = controller?.googleTileset || gev.tileset || null;
|
||||
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
|
||||
if (!gev.viewer) return {
|
||||
tilesSettled: null, tileSettleApplicable: false, activeStack,
|
||||
reason: 'Navara exposes view idle, not tile readiness independently of animated layers',
|
||||
};
|
||||
if (!applicable) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -166,7 +181,8 @@ async function awaitTilesSettled(page, scenario) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
if (gev.viewer) gev.viewer.scene.render(gev.viewer.clock.currentTime);
|
||||
else gev.engine.render.request('focus-evidence');
|
||||
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
|
||||
return {
|
||||
tilesSettled: true,
|
||||
@@ -203,7 +219,7 @@ async function capture(page, scenario, frame) {
|
||||
// WebGL surface is paused. Reading the explicitly rendered canvas captures
|
||||
// that same frame without handing frame ownership back to the browser.
|
||||
const dataUrl = await page.evaluate(() => (
|
||||
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
|
||||
window.__godsEyeView.engine.host.canvas.toDataURL('image/png')
|
||||
));
|
||||
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
|
||||
return { file: out, ...tileReadiness };
|
||||
@@ -229,16 +245,17 @@ async function takeFrameClock(page) {
|
||||
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
|
||||
return page.evaluate((stepMs) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const viewer = gev.viewer;
|
||||
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
|
||||
const nowMs = seam.advanceFrameClock(stepMs);
|
||||
const JulianDate = viewer.clock.currentTime.constructor;
|
||||
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
|
||||
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
|
||||
viewer.scene.render(viewer.clock.currentTime);
|
||||
// Cross a task boundary so Chrome can commit the explicitly rendered
|
||||
// surface before capture. Cesium's drained default loop stays disabled.
|
||||
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { remove(); reject(new Error('Evidence frame timeout')); }, 5000);
|
||||
const remove = gev.engine.frames.postRender.addEventListener(() => {
|
||||
remove(); clearTimeout(timer);
|
||||
setTimeout(() => resolve(nowMs), 0);
|
||||
});
|
||||
gev.engine.render.request('focus-evidence');
|
||||
});
|
||||
|
||||
}, deltaMs);
|
||||
}
|
||||
|
||||
@@ -266,6 +283,7 @@ async function installSyntheticFetches(page) {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input?.url || '';
|
||||
if (url.includes('/api/flights/track')) return json({ path: [] });
|
||||
@@ -281,9 +299,10 @@ async function installSyntheticFetches(page) {
|
||||
async function waitForApp(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||||
() => Boolean(window.__godsEyeView?.layers && window.__godsEyeView?.dataManager),
|
||||
{ timeout: 60_000, polling: 200 },
|
||||
);
|
||||
await page.evaluate(async () => { window.__focusGeo = await import('/src/geo/index.js'); });
|
||||
}
|
||||
|
||||
async function setBasemap(page, stackId) {
|
||||
@@ -317,7 +336,18 @@ async function requireEvidenceSeams(page) {
|
||||
|
||||
async function setCamera(page, values) {
|
||||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const app = window.__godsEyeView;
|
||||
if (!app.viewer) {
|
||||
const geo = window.__focusGeo;
|
||||
app.engine.camera.following()?.stop();
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(lon, lat, height),
|
||||
orientation: { heading, pitch, roll: 0 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const viewer = app.viewer;
|
||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -345,7 +375,7 @@ async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
|
||||
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.waitForFunction(
|
||||
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
|
||||
() => Boolean(window.__godsEyeView.engine.camera.following()),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await advanceEvidenceDuration(page, 400);
|
||||
@@ -356,7 +386,8 @@ async function readTrackedBasis(page) {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
|
||||
const camera = gev.viewer.camera;
|
||||
const pose = gev.engine.camera.pose();
|
||||
const camera = gev.viewer?.camera ?? { positionWC: pose.position, rightWC: pose.right, upWC: pose.up, frustum: { fovy: pose.fovy } };
|
||||
if (!target) throw new Error('Tracked target position unavailable');
|
||||
const toTarget = {
|
||||
x: target.x - camera.positionWC.x,
|
||||
@@ -364,7 +395,7 @@ async function readTrackedBasis(page) {
|
||||
z: target.z - camera.positionWC.z,
|
||||
};
|
||||
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
|
||||
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
|
||||
const canvasHeight = gev.engine.host.size().height;
|
||||
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
|
||||
return {
|
||||
target: [target.x, target.y, target.z],
|
||||
@@ -427,8 +458,8 @@ async function vesselRowsAroundTarget(page) {
|
||||
const gev = window.__godsEyeView;
|
||||
const layer = gev.dataManager.layers.get('flights').module;
|
||||
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
|
||||
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const Cartographic = { fromCartesian: window.__focusGeo.cartographic.fromCartesian };
|
||||
const ellipsoid = window.__focusGeo.ellipsoid.WGS84;
|
||||
const rows = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
@@ -624,7 +655,9 @@ async function browserContext(page, browser) {
|
||||
},
|
||||
caveat: HEADFUL
|
||||
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
|
||||
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
|
||||
: process.env.QA_ANGLE === 'metal'
|
||||
? 'Headless ANGLE/Metal capture; renderer and DPR recorded above.'
|
||||
: 'SwiftShader/headless output is relative-only.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -641,7 +674,7 @@ async function main() {
|
||||
headless: HEADFUL ? false : 'new',
|
||||
protocolTimeout: 300000,
|
||||
args: [
|
||||
...(HEADFUL ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
...(HEADFUL ? [] : process.env.QA_ANGLE ? ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE}`] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
@@ -655,10 +688,10 @@ async function main() {
|
||||
const consoleMessages = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||||
consoleMessages.push({ type: message.type(), text: redact(message.text()) });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: redact(error.message) }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
|
||||
+321
-42
@@ -1,19 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Keyless station-name regression against the real globe and bike-share tool.
|
||||
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||
* Run with a dev or QA preview server and an explicitly assigned --url.
|
||||
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import sharp from 'sharp';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { watchBrowserPage, redact } from './qa-browserEvidence.mjs';
|
||||
|
||||
const urlIndex = process.argv.indexOf('--url');
|
||||
const appUrl =
|
||||
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||
const shots = path.resolve('qa-shots/gbfs-names');
|
||||
if (urlIndex < 0) throw new Error('An explicit --url is required');
|
||||
const appUrl = process.argv[urlIndex + 1];
|
||||
const engineName = new URL(appUrl).searchParams.get('engine') || 'cesium';
|
||||
const extended = process.argv.includes('--w2');
|
||||
const reportIndex = process.argv.indexOf('--report');
|
||||
const reportPath =
|
||||
reportIndex < 0 ? null : path.resolve(process.argv[reportIndex + 1]);
|
||||
const shots = reportPath
|
||||
? reportPath.replace(/\.json$/, '-shots')
|
||||
: path.resolve(`qa-shots/gbfs-names/${engineName}`);
|
||||
fs.mkdirSync(shots, { recursive: true });
|
||||
const report = { engine: engineName, checks: [], poses: {}, browserErrors: [] };
|
||||
const information = {
|
||||
version: '3.0',
|
||||
data: {
|
||||
@@ -67,6 +78,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
let checks = 0;
|
||||
const check = (name, actual, expected) => {
|
||||
report.checks.push({ name, actual, expected });
|
||||
assert.deepEqual(actual, expected, name);
|
||||
checks++;
|
||||
console.log(`[PASS] ${name}`);
|
||||
@@ -75,6 +87,12 @@ async function open(mode = 'normal') {
|
||||
const page = await browser.newPage();
|
||||
await page.bringToFront();
|
||||
const errors = [];
|
||||
if (extended)
|
||||
watchBrowserPage(page, report, {
|
||||
name: mode,
|
||||
baseUrl: appUrl,
|
||||
preview: process.argv.includes('--preview'),
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewport({ width: 1440, height: 900 });
|
||||
await page.evaluateOnNewDocument(
|
||||
@@ -133,23 +151,236 @@ async function open(mode = 'normal') {
|
||||
'0'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { dataManager, viewer } = window.__godsEyeView;
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
window.__godsEyeView.requestRender('gbfs-name-qa');
|
||||
});
|
||||
await page.evaluate(
|
||||
async (destination) => {
|
||||
const { dataManager, engine } = window.__godsEyeView;
|
||||
await window.__godsEyeView.styleManager.initialRestorePromise;
|
||||
window.__godsEyeView.styleManager.orbitController.stop();
|
||||
for (const [id, layer] of dataManager.layers) {
|
||||
if (layer.enabled)
|
||||
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||
}
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||
window.__godsEyeView.requestRender('gbfs-name-qa');
|
||||
},
|
||||
fromDegrees(-97.7449, 30.2701, 3000),
|
||||
);
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
async function nextFrame(page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('gbfs-raster-qa');
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function settledTiles(page) {
|
||||
await page.evaluate(async () => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const start = performance.now();
|
||||
let since = null;
|
||||
while (performance.now() - start < 90000) {
|
||||
if (engine.surface.tilesLoaded()) since ??= performance.now();
|
||||
else since = null;
|
||||
if (since !== null && performance.now() - since > 1500) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('Map tiles did not settle for raster sampling');
|
||||
});
|
||||
}
|
||||
|
||||
async function w2Checks(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
const detection = await import('/src/data/detection.js');
|
||||
detection.setMode('OFF');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
appClock.freeze(Date.parse('2026-10-28T19:00:00Z'));
|
||||
const canvas = app.engine.host.canvas;
|
||||
const style = document.createElement('style');
|
||||
style.textContent =
|
||||
'.gbfs-raster-hidden { visibility: hidden !important; }';
|
||||
document.head.append(style);
|
||||
for (const element of document.body.querySelectorAll('*'))
|
||||
if (element !== canvas && !element.contains(canvas))
|
||||
element.classList.add('gbfs-raster-hidden');
|
||||
});
|
||||
for (const [name, height] of [
|
||||
['city', 10000],
|
||||
['close', 3000],
|
||||
['global', 100000],
|
||||
]) {
|
||||
await page.evaluate(
|
||||
async (destination) => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
app.engine.camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
},
|
||||
fromDegrees(-97.7449, 30.2701, height),
|
||||
);
|
||||
await settledTiles(page);
|
||||
await nextFrame(page);
|
||||
const off = await page.screenshot({
|
||||
path: path.join(shots, `${name}-off.png`),
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(count) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === count,
|
||||
{},
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
await nextFrame(page);
|
||||
const controls = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
const rect = app.engine.host.canvas.getBoundingClientRect();
|
||||
return app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.map((record) => {
|
||||
const p = app.engine.projector.worldToWindow(record.position);
|
||||
return { id: record.sourceId, x: p.x + rect.left, y: p.y + rect.top };
|
||||
});
|
||||
});
|
||||
const on = await page.screenshot({
|
||||
path: path.join(shots, `${name}-on.png`),
|
||||
});
|
||||
const a = await sharp(off)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const b = await sharp(on).ensureAlpha().raw().toBuffer();
|
||||
for (const control of controls) {
|
||||
let pixels = 0;
|
||||
for (
|
||||
let y = Math.max(0, Math.floor(control.y) - 16);
|
||||
y < Math.min(a.info.height, control.y + 16);
|
||||
y++
|
||||
)
|
||||
for (
|
||||
let x = Math.max(0, Math.floor(control.x) - 16);
|
||||
x < Math.min(a.info.width, control.x + 16);
|
||||
x++
|
||||
) {
|
||||
const i = (y * a.info.width + x) * 4;
|
||||
if (
|
||||
Math.max(
|
||||
...[0, 1, 2].map((c) => Math.abs(a.data[i + c] - b[i + c])),
|
||||
) > 3
|
||||
)
|
||||
pixels++;
|
||||
}
|
||||
control.changedPixels = pixels;
|
||||
}
|
||||
report.poses[name] = controls;
|
||||
check(
|
||||
`${name} expected station population`,
|
||||
controls.length,
|
||||
name === 'global' ? 0 : 5,
|
||||
);
|
||||
if (controls.length)
|
||||
check(
|
||||
`${name} five nonempty marker raster controls`,
|
||||
controls.every((c) => c.changedPixels > 0),
|
||||
true,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
for (const element of document.querySelectorAll('.gbfs-raster-hidden'))
|
||||
element.classList.remove('gbfs-raster-hidden');
|
||||
});
|
||||
await page.evaluate(
|
||||
async (destination) => {
|
||||
const app = window.__godsEyeView;
|
||||
app.engine.camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
await app.dataManager.setEnabled('bikeshare', false);
|
||||
},
|
||||
fromDegrees(-97.7449, 30.2701, 3000),
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count === 5,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', false),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'ten cycles clear all records',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getStats().count,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const idle = await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
let frames = 0,
|
||||
last = performance.now();
|
||||
const remove = app.engine.frames.postRender.addEventListener(() => {
|
||||
frames++;
|
||||
last = performance.now();
|
||||
});
|
||||
const start = performance.now();
|
||||
while (
|
||||
performance.now() - start < 60000 &&
|
||||
(performance.now() - last < 2000 || !app.engine.surface.tilesLoaded())
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const ready =
|
||||
performance.now() - last >= 2000 && app.engine.surface.tilesLoaded();
|
||||
frames = 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
remove();
|
||||
return { ready, frames, governor: app.getRenderGovernorDiagnostics() };
|
||||
});
|
||||
report.idle = idle;
|
||||
check('camera and tiles settle before idle sample', idle.ready, true);
|
||||
check(
|
||||
'disabled static scene renders zero frames over two seconds',
|
||||
idle.frames,
|
||||
0,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('bikeshare', true),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||
.count === 5,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const { page, errors } = await open();
|
||||
await page.waitForFunction(
|
||||
@@ -158,6 +389,17 @@ try {
|
||||
.count === 5,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const rendered = new Promise((resolve) => {
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
window.__godsEyeView.requestRender('gbfs-name-pick-qa');
|
||||
await rendered;
|
||||
});
|
||||
const records = await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
@@ -198,17 +440,39 @@ try {
|
||||
),
|
||||
true,
|
||||
);
|
||||
if (extended) await w2Checks(page);
|
||||
const clickPoint = await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||
);
|
||||
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||
const { engine } = window.__godsEyeView;
|
||||
const station = window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.find(({ sourceId }) => sourceId === 'austin-capmetro:english');
|
||||
const point = engine.projector.worldToWindow(station.position);
|
||||
const rect = engine.host.canvas.getBoundingClientRect();
|
||||
const picked = engine.picker.pick(point);
|
||||
return {
|
||||
x: rect.left + point.x,
|
||||
y: rect.top + point.y,
|
||||
pickedId: typeof picked?.id === 'string' ? picked.id : null,
|
||||
primitiveId:
|
||||
typeof picked?.primitive?.id === 'string' ? picked.primitive.id : null,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'the scene pick resolves the first stable station key',
|
||||
clickPoint.pickedId || clickPoint.primitiveId,
|
||||
'austin-capmetro:english',
|
||||
);
|
||||
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module.getDetectableObjects()
|
||||
.some((record) => record.skipLabel),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
@@ -263,22 +527,24 @@ try {
|
||||
);
|
||||
}
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.evaluate(() => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||
app.viewer.camera.cancelFlight();
|
||||
app.viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest('austin-capmetro:english');
|
||||
app.requestRender('gbfs-name-mobile-qa');
|
||||
});
|
||||
await page.evaluate(
|
||||
async (destination) => {
|
||||
window.__qaPainted = [];
|
||||
const app = window.__godsEyeView;
|
||||
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||
app.styleManager.toggleCleanView(true);
|
||||
app.engine.camera.cancelFlight();
|
||||
app.engine.camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
app.dataManager.layers
|
||||
.get('bikeshare')
|
||||
.module._selectBikeshareStationForTest('austin-capmetro:english');
|
||||
app.requestRender('gbfs-name-mobile-qa');
|
||||
},
|
||||
fromDegrees(-97.7449, 30.2701, 3000),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__qaPainted.includes('QA English Square'),
|
||||
{ timeout: 10000 },
|
||||
@@ -330,7 +596,20 @@ try {
|
||||
check(`${mode} produces no page exceptions`, errors, []);
|
||||
await page.close();
|
||||
}
|
||||
if (extended)
|
||||
check(
|
||||
'all pages have no unexpected runtime errors',
|
||||
report.browserErrors,
|
||||
[],
|
||||
);
|
||||
report.pass = true;
|
||||
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||
} catch (error) {
|
||||
report.pass = false;
|
||||
report.failure = redact(error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
if (reportPath)
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n');
|
||||
await browser.close();
|
||||
}
|
||||
+132
-63
@@ -7,9 +7,13 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installImportProbe } from './qa-geo-import-probe.mjs';
|
||||
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||
|
||||
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const base =
|
||||
process.argv.find((arg) => /^https?:/.test(arg)) ||
|
||||
process.env.QA_BASE_URL ||
|
||||
'http://localhost:4173';
|
||||
const supplied = process.argv.includes('--file')
|
||||
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
|
||||
: null;
|
||||
@@ -90,6 +94,7 @@ try {
|
||||
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await installImportProbe(page);
|
||||
await page.evaluate(async () => {
|
||||
const { viewer, dataManager } = window.__godsEyeView;
|
||||
const C = window.__CESIUM__;
|
||||
@@ -103,49 +108,110 @@ try {
|
||||
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
const rgba = (color) => [color.red, color.green, color.blue, color.alpha];
|
||||
const xyz = (p) => [p.x, p.y, p.z];
|
||||
const lonLat = (p) => {
|
||||
const c = C.Cartographic.fromCartesian(p);
|
||||
return [C.Math.toDegrees(c.longitude), C.Math.toDegrees(c.latitude)];
|
||||
};
|
||||
const canonical = (rows) =>
|
||||
rows
|
||||
.map((row) =>
|
||||
JSON.stringify(row, (_, value) =>
|
||||
typeof value === 'number'
|
||||
? Math.round(value * 1e5) / 1e5 + 0
|
||||
: value,
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
window.__qaStyleSnapshot = (source) => {
|
||||
const now = viewer.clock.currentTime;
|
||||
const images = new Map();
|
||||
const imageValue = (p) => {
|
||||
const value = p?.getValue(now);
|
||||
if (value?.tagName !== 'CANVAS') return value;
|
||||
if (!images.has(value)) images.set(value, value.toDataURL());
|
||||
return images.get(value);
|
||||
};
|
||||
const color = (p) => p?.getValue(now)?.toCssColorString();
|
||||
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
|
||||
const renderedPins = new Map();
|
||||
const collection = source.importedBillboards;
|
||||
if (collection)
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const pin = collection.get(i);
|
||||
renderedPins.set(pin.id, pin);
|
||||
const value = (property) => property?.getValue(now);
|
||||
const rows = [];
|
||||
for (const e of source.entities.values) {
|
||||
if (e.billboard && e.position) {
|
||||
const b = e.billboard,
|
||||
image = value(b.image);
|
||||
const scale = value(b.scale) ?? 1;
|
||||
const width = (value(b.width) ?? image.width) * scale;
|
||||
const height = (value(b.height) ?? image.height) * scale;
|
||||
const offset = value(b.pixelOffset) ?? { x: 0, y: 0 };
|
||||
const centerY =
|
||||
offset.y -
|
||||
(value(b.verticalOrigin) === C.VerticalOrigin.BOTTOM
|
||||
? height / 2
|
||||
: 0);
|
||||
// A clamped billboard keeps its file height and Cesium clamps it
|
||||
// while drawing; the import draws its pins at the sampled ground.
|
||||
// Both compare by longitude and latitude.
|
||||
const clamped =
|
||||
(value(b.heightReference) ?? C.HeightReference.NONE) !==
|
||||
C.HeightReference.NONE;
|
||||
rows.push({
|
||||
kind: 'pin',
|
||||
position: (clamped ? lonLat : xyz)(value(e.position)),
|
||||
size:
|
||||
2 *
|
||||
Math.max(
|
||||
Math.abs(offset.x) + width / 2,
|
||||
Math.abs(centerY) + height / 2,
|
||||
0.5,
|
||||
),
|
||||
rotation: value(b.rotation) ?? 0,
|
||||
color: rgba(value(b.color) ?? C.Color.WHITE),
|
||||
image: Boolean(image),
|
||||
});
|
||||
}
|
||||
const actual = (e, key, fallback) => {
|
||||
const pin = renderedPins.get(e);
|
||||
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
|
||||
};
|
||||
return (source.importedEntities || source.entities.values)
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
point: renderedPins.get(e)?.position || e.position?.getValue(now),
|
||||
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
|
||||
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
|
||||
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
|
||||
color: e.billboard
|
||||
? actual(e, 'color', C.Color.WHITE).toCssColorString()
|
||||
: undefined,
|
||||
width: e.polyline?.width?.getValue(now),
|
||||
lineColor: color(e.polyline?.material?.color),
|
||||
line: positions(e.polyline?.positions),
|
||||
fill: e.polygon?.fill?.getValue(now),
|
||||
outline: e.polygon?.outline?.getValue(now),
|
||||
polygonColor: color(e.polygon?.material?.color),
|
||||
polygon: e.polygon?.hierarchy
|
||||
?.getValue(now)
|
||||
?.positions?.map((v) => [v.x, v.y, v.z]),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (e.polyline)
|
||||
rows.push({
|
||||
kind: 'line',
|
||||
positions: value(e.polyline.positions).map(lonLat),
|
||||
width: value(e.polyline.width),
|
||||
color: rgba(value(e.polyline.material.color)),
|
||||
});
|
||||
if (e.polygon && value(e.polygon.fill) !== false) {
|
||||
const hierarchy = value(e.polygon.hierarchy);
|
||||
rows.push({
|
||||
kind: 'polygon',
|
||||
rings: [
|
||||
hierarchy.positions,
|
||||
...hierarchy.holes.map((hole) => hole.positions),
|
||||
].map((ring) => ring.map(lonLat)),
|
||||
fill: rgba(value(e.polygon.material.color)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return canonical(rows);
|
||||
};
|
||||
window.__qaDrawnSnapshot = () => {
|
||||
const rows = [];
|
||||
for (const r of window.__qaImports.records()) {
|
||||
if (r.kind === 'movers')
|
||||
for (const item of r.items)
|
||||
rows.push({
|
||||
kind: 'pin',
|
||||
position: (r.options.depthTest === 'always' ? lonLat : xyz)(
|
||||
item.position,
|
||||
),
|
||||
size: r.options.sprite.sizePx,
|
||||
rotation: item.rotation,
|
||||
color: item.color,
|
||||
image: r.options.sprite.atlas.canvas.width > 0,
|
||||
});
|
||||
if (r.kind === 'ground')
|
||||
for (const item of r.items)
|
||||
rows.push(
|
||||
r.options.kind === 'corridor'
|
||||
? {
|
||||
kind: 'line',
|
||||
positions: item.positions,
|
||||
width: item.widthPx,
|
||||
color: item.color,
|
||||
}
|
||||
: { kind: 'polygon', rings: item.rings, fill: item.fill },
|
||||
);
|
||||
}
|
||||
return canonical(rows);
|
||||
};
|
||||
window.__qaUnsharedKml = async (text) => {
|
||||
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||
@@ -170,14 +236,15 @@ try {
|
||||
const outcomes = await window.__gevGeoImport.importFiles([
|
||||
new File([text], 'style-equivalence.kml'),
|
||||
]);
|
||||
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||
const candidate = window.__gevGeoImport
|
||||
.list()
|
||||
.find((file) => file.id === outcomes[0]?.id);
|
||||
const result = {
|
||||
ok: outcomes[0]?.ok,
|
||||
same:
|
||||
JSON.stringify(window.__qaStyleSnapshot(control)) ===
|
||||
JSON.stringify(window.__qaStyleSnapshot(candidate)),
|
||||
entities: (candidate.importedEntities || candidate.entities.values)
|
||||
.length,
|
||||
JSON.stringify(window.__qaDrawnSnapshot()),
|
||||
entities: candidate?.entityCount,
|
||||
};
|
||||
await window.__gevGeoImport.remove(outcomes[0]?.id);
|
||||
return result;
|
||||
@@ -251,7 +318,7 @@ try {
|
||||
|
||||
const loadPicked = async (filename, expected) => {
|
||||
const before = await page.evaluate(
|
||||
() => window.__godsEyeView.viewer.dataSources.length,
|
||||
() => window.__qaImports.groups().length,
|
||||
);
|
||||
const priorIds = await page.evaluate(() =>
|
||||
window.__gevGeoImport.list().map((f) => f.id),
|
||||
@@ -276,7 +343,7 @@ try {
|
||||
const state = await page.evaluate(() => ({
|
||||
files: window.__gevGeoImport.list(),
|
||||
hint: document.getElementById('geo-import-hint').textContent,
|
||||
sources: window.__godsEyeView.viewer.dataSources.length,
|
||||
groups: window.__qaImports.groups().length,
|
||||
}));
|
||||
const item = state.files.find((f) => !priorIds.includes(f.id));
|
||||
check(
|
||||
@@ -285,7 +352,7 @@ try {
|
||||
(expected === null
|
||||
? item?.entityCount > 0
|
||||
: item?.entityCount === expected) &&
|
||||
state.sources === before + 1,
|
||||
state.groups === before + 1,
|
||||
JSON.stringify({
|
||||
bytes: fs.statSync(filename).size,
|
||||
loadMs: Math.round(performance.now() - started),
|
||||
@@ -297,10 +364,8 @@ try {
|
||||
const item = await loadPicked(file, 5000);
|
||||
if (item) {
|
||||
const same = await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||
return (
|
||||
JSON.stringify(window.__qaStyleSnapshot(source)) ===
|
||||
JSON.stringify(window.__qaDrawnSnapshot()) ===
|
||||
window.__qaUnsharedSnapshot
|
||||
);
|
||||
});
|
||||
@@ -338,17 +403,15 @@ try {
|
||||
const markers = await page.evaluate(() => {
|
||||
const { viewer } = window.__godsEyeView,
|
||||
C = window.__CESIUM__;
|
||||
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||
const points = (
|
||||
source.importedEntities || source.entities.values
|
||||
).filter((e) => e.position && e.billboard);
|
||||
const images = points.filter((e) =>
|
||||
e.billboard.image?.getValue(viewer.clock.currentTime),
|
||||
);
|
||||
const sets = window.__qaImports
|
||||
.records()
|
||||
.filter((record) => record.kind === 'movers');
|
||||
const points = sets.flatMap((record) => record.items);
|
||||
const images = sets
|
||||
.filter((record) => record.options.sprite.atlas.canvas.width > 0)
|
||||
.flatMap((record) => record.items);
|
||||
if (images.length) {
|
||||
const at = C.Cartographic.fromCartesian(
|
||||
images[0].position.getValue(viewer.clock.currentTime),
|
||||
);
|
||||
const at = C.Cartographic.fromCartesian(images[0].position);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromRadians(
|
||||
@@ -436,8 +499,14 @@ try {
|
||||
JSON.stringify(errors),
|
||||
);
|
||||
check(
|
||||
'all imported sources are removed',
|
||||
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
|
||||
'all imported files and drawing sets are removed',
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__gevGeoImport.list().length === 0 &&
|
||||
Object.values(window.__qaImports.list()).every(
|
||||
(list) => list.length === 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log('Fixture retained at', file);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Record import writes at the globe contract, including per-instance styles. */
|
||||
export async function installImportProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
const engine = window.__godsEyeView.engine;
|
||||
const records = new Map();
|
||||
for (const [kind, method] of [
|
||||
['movers', 'createSet'],
|
||||
['ground', 'createSet'],
|
||||
['lines', 'createLine'],
|
||||
['imagery', 'createLayer'],
|
||||
]) {
|
||||
const create = engine[kind][method].bind(engine[kind]);
|
||||
engine[kind][method] = (options) => {
|
||||
const handle = create(options);
|
||||
if (!options.id.startsWith('geo-import-')) return handle;
|
||||
const record = { kind, options, handle, items: [], positions: [] };
|
||||
records.set(options.id, record);
|
||||
const wrap = (name, after) => {
|
||||
const original = handle[name].bind(handle);
|
||||
handle[name] = (...args) => {
|
||||
const result = original(...args);
|
||||
after(...args);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
if (kind === 'movers')
|
||||
wrap('set', (index, fields) => {
|
||||
record.items[index] = { ...record.items[index], ...fields };
|
||||
});
|
||||
if (kind === 'ground')
|
||||
wrap('replace', (items) => {
|
||||
record.items = items;
|
||||
});
|
||||
if (kind === 'lines')
|
||||
wrap('setPositions', (positions) => {
|
||||
record.positions = positions;
|
||||
});
|
||||
wrap('destroy', () => records.delete(options.id));
|
||||
return handle;
|
||||
};
|
||||
}
|
||||
window.__qaImports = {
|
||||
list: () =>
|
||||
Object.fromEntries(
|
||||
['movers', 'ground', 'lines', 'imagery'].map((kind) => [
|
||||
kind,
|
||||
engine[kind]
|
||||
.list()
|
||||
.filter(({ id }) => id.startsWith('geo-import-')),
|
||||
]),
|
||||
),
|
||||
records: () => [...records.values()],
|
||||
groups: () => {
|
||||
const groups = new Map();
|
||||
for (const record of records.values()) {
|
||||
const id = record.options.id.match(/^geo-import-\d+/)[0];
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
groups.get(id).push(record);
|
||||
}
|
||||
return [...groups.values()];
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/** Import regressions through the real UI controller and Cesium loaders.
|
||||
/** Import regressions through the real UI controller and globe primitives.
|
||||
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installImportProbe } from './qa-geo-import-probe.mjs';
|
||||
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
@@ -18,7 +19,9 @@ const meshBytes = Buffer.alloc(60);
|
||||
const fixture = {
|
||||
'/__qa_import_surface/tileset.json': {
|
||||
asset: { version: '1.1' },
|
||||
geometricError: 0,
|
||||
// Cesium draws no tileset whose own error is 0: the mesh must draw to be
|
||||
// the rendered surface.
|
||||
geometricError: 10000,
|
||||
root: {
|
||||
transform: Cesium.Matrix4.toArray(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
@@ -86,7 +89,7 @@ let failed = 0;
|
||||
try {
|
||||
await page.setViewport({ width: 390, height: 844 });
|
||||
await page.goto(
|
||||
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
`${process.argv.find((arg) => /^https?:/.test(arg)) || process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
@@ -112,8 +115,9 @@ try {
|
||||
page.on('request', (r) => {
|
||||
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
|
||||
});
|
||||
await installImportProbe(page);
|
||||
const results = await page.evaluate(async () => {
|
||||
const { viewer } = window.__godsEyeView,
|
||||
const { viewer, engine } = window.__godsEyeView,
|
||||
C = window.__CESIUM__,
|
||||
tool = window.__gevGeoImport;
|
||||
const results = [],
|
||||
@@ -128,14 +132,27 @@ try {
|
||||
typeof value === 'object' && !ArrayBuffer.isView(value)
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
const before = new Set(window.__qaImports.records());
|
||||
const [outcome] = await tool.importFiles([new File([content], name)]);
|
||||
return {
|
||||
outcome,
|
||||
source: outcome.ok
|
||||
? viewer.dataSources.get(viewer.dataSources.length - 1)
|
||||
: null,
|
||||
drawing: window.__qaImports
|
||||
.records()
|
||||
.filter((record) => !before.has(record)),
|
||||
};
|
||||
};
|
||||
const pins = (r) =>
|
||||
r.drawing
|
||||
.filter((record) => record.kind === 'movers')
|
||||
.flatMap((record) => record.items);
|
||||
const ground = (r) =>
|
||||
r.drawing
|
||||
.filter((record) => record.kind === 'ground')
|
||||
.flatMap((record) => record.items);
|
||||
const lines = (r) => r.drawing.filter((record) => record.kind === 'lines');
|
||||
const screenOf = (p) => engine.projector.worldToWindow(p);
|
||||
const inFrame = (p) =>
|
||||
p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight;
|
||||
const remove = (r) => {
|
||||
if (r.outcome.ok) tool.remove(r.outcome.id);
|
||||
};
|
||||
@@ -171,26 +188,34 @@ try {
|
||||
},
|
||||
'xyz.geojson',
|
||||
);
|
||||
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
|
||||
(e) => e.billboard,
|
||||
),
|
||||
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
|
||||
(e) => e.polygon,
|
||||
);
|
||||
// The file's 100 m is dropped: the pin is on the ground sampled under
|
||||
// it, the globe's tiles drawn (here the ellipsoid, 0 m, once they have
|
||||
// loaded and the import has read them again).
|
||||
const pinHeight = () =>
|
||||
C.Cartographic.fromCartesian(pins(r)[0].position).height;
|
||||
const groundDeadline = performance.now() + 15000;
|
||||
while (
|
||||
Math.abs(pinHeight()) >= 0.01 &&
|
||||
performance.now() < groundDeadline
|
||||
) {
|
||||
viewer.scene.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
const pin = pins(r)[0],
|
||||
polygon = ground(r)[0];
|
||||
check(
|
||||
'XYZ ground footprint and isolated entity IDs',
|
||||
'XYZ ground footprint and isolated pick IDs',
|
||||
r.outcome.ok &&
|
||||
pin.id !== 'kartaview-owned' &&
|
||||
Math.abs(
|
||||
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
|
||||
) < 0.01 &&
|
||||
polygon.polygon.perPositionHeight?.getValue(now) !== true,
|
||||
pin.pickId === undefined &&
|
||||
Math.abs(pinHeight()) < 0.01 &&
|
||||
polygon.rings.flat().every((p) => p.length === 2),
|
||||
{ pinHeight: pinHeight() },
|
||||
);
|
||||
check(
|
||||
'ground pins have no persistent clamping callbacks',
|
||||
pin?.billboard.heightReference?.getValue(now) ===
|
||||
C.HeightReference.NONE &&
|
||||
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
|
||||
'ground pins use fixed positions and ignore scene depth',
|
||||
typeof pin.position.x === 'number' &&
|
||||
r.drawing.find((record) => record.kind === 'movers').options
|
||||
.depthTest === 'always',
|
||||
);
|
||||
remove(r);
|
||||
r = await load(
|
||||
@@ -201,24 +226,18 @@ try {
|
||||
),
|
||||
'tracks.kml',
|
||||
);
|
||||
const find = (name) =>
|
||||
(r.source?.importedEntities || r.source?.entities.values).find(
|
||||
(e) => e.name === name,
|
||||
);
|
||||
check(
|
||||
'ground tracks and untessellated authored lines clamp',
|
||||
find('ground')?.polyline.clampToGround?.getValue(now) &&
|
||||
find('line')?.polyline.clampToGround?.getValue(now),
|
||||
ground(r).length === 2,
|
||||
);
|
||||
check(
|
||||
'explicit track altitude and original vertices survive',
|
||||
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
|
||||
find('absolute')
|
||||
.polyline.positions.getValue(now)
|
||||
.every(
|
||||
(p) =>
|
||||
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
|
||||
),
|
||||
lines(r).length === 1 &&
|
||||
lines(r)[0].positions.length === 2 &&
|
||||
lines(r)[0].positions.every(
|
||||
(p) =>
|
||||
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
|
||||
),
|
||||
);
|
||||
remove(r);
|
||||
r = await load(
|
||||
@@ -245,10 +264,7 @@ try {
|
||||
);
|
||||
check(
|
||||
'import show controls own previously hidden KML content',
|
||||
r.outcome.ok &&
|
||||
(r.source.importedEntities || r.source.entities.values).every(
|
||||
(e) => e.isShowing,
|
||||
),
|
||||
r.outcome.ok && pins(r).length === 1 && pins(r).every((pin) => pin.show),
|
||||
);
|
||||
remove(r);
|
||||
r = await load(
|
||||
@@ -260,9 +276,8 @@ try {
|
||||
check(
|
||||
'a colored overlay without an icon is drawable',
|
||||
r.outcome.ok &&
|
||||
(r.source.importedEntities || r.source.entities.values).some(
|
||||
(e) => e.rectangle,
|
||||
),
|
||||
ground(r).length === 1 &&
|
||||
ground(r)[0].fill.join() === '0,1,0,1',
|
||||
);
|
||||
remove(r);
|
||||
const xml = wrap(`<Placemark>${point}</Placemark>`);
|
||||
@@ -325,11 +340,10 @@ try {
|
||||
'portrait.geojson',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||
const positions = (r.source?.importedEntities ||
|
||||
r.source?.entities.values)[0].polyline.positions.getValue(now);
|
||||
const screen = positions?.map((p) =>
|
||||
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
|
||||
const positions = ground(r)[0].positions.map(([lon, lat]) =>
|
||||
C.Cartesian3.fromDegrees(lon, lat),
|
||||
);
|
||||
const screen = positions.map(screenOf);
|
||||
check(
|
||||
'portrait flight includes both route endpoints',
|
||||
screen?.every(
|
||||
@@ -349,28 +363,13 @@ try {
|
||||
'intercontinental.geojson',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||
const intercontinental = (
|
||||
r.source?.importedEntities || r.source?.entities.values
|
||||
)
|
||||
.filter((e) => e.position)
|
||||
.map((e) => e.position.getValue(now));
|
||||
const occluder = new C.EllipsoidalOccluder(
|
||||
C.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const intercontinental = pins(r).map((pin) => pin.position);
|
||||
check(
|
||||
'intercontinental points remain in frame and in front of the horizon',
|
||||
intercontinental?.every((p) => {
|
||||
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
|
||||
return (
|
||||
occluder.isPointVisible(p) &&
|
||||
s &&
|
||||
s.x > 0 &&
|
||||
s.x < innerWidth &&
|
||||
s.y > 0 &&
|
||||
s.y < innerHeight
|
||||
);
|
||||
}),
|
||||
intercontinental.length === 2 &&
|
||||
intercontinental.every(
|
||||
(p) => engine.projector.horizonVisible(p) && inFrame(screenOf(p)),
|
||||
),
|
||||
);
|
||||
remove(r);
|
||||
r = await load(
|
||||
@@ -381,20 +380,12 @@ try {
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||
check(
|
||||
'a wall contributes both endpoints to the flight',
|
||||
'a wall top edge contributes both endpoints to the flight',
|
||||
r.outcome.ok &&
|
||||
(r.source.importedEntities || r.source.entities.values)
|
||||
.find((e) => e.wall)
|
||||
?.wall.positions.getValue(now)
|
||||
.every((p) => {
|
||||
const s = C.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
p,
|
||||
);
|
||||
return (
|
||||
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
|
||||
);
|
||||
}),
|
||||
lines(r).length === 1 &&
|
||||
lines(r)[0].positions.length === 2 &&
|
||||
lines(r)[0].positions.every((p) => inFrame(screenOf(p))) &&
|
||||
tool.list().find((file) => file.id === r.outcome.id).undrawn === 1,
|
||||
);
|
||||
remove(r);
|
||||
r = await load(
|
||||
@@ -447,9 +438,7 @@ try {
|
||||
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
|
||||
'mesh-point.geojson',
|
||||
);
|
||||
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
|
||||
.find((e) => e.billboard)
|
||||
?.position.getValue(now);
|
||||
const meshPoint = pins(r)[0]?.position;
|
||||
const meshHeight =
|
||||
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
|
||||
check(
|
||||
@@ -463,9 +452,7 @@ try {
|
||||
);
|
||||
tool.flyTo(r.outcome.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||
const meshScreen =
|
||||
meshPoint &&
|
||||
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
|
||||
const meshScreen = meshPoint && screenOf(meshPoint);
|
||||
check(
|
||||
'a high-surface arrival frames the elevated pin',
|
||||
meshScreen &&
|
||||
@@ -480,7 +467,7 @@ try {
|
||||
meshHeight,
|
||||
},
|
||||
);
|
||||
const groundSource = r.source;
|
||||
const groundDrawing = r;
|
||||
const elevated = await load(
|
||||
wrap(
|
||||
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
|
||||
@@ -492,9 +479,8 @@ try {
|
||||
'a higher imported pin never becomes the sampled ground',
|
||||
elevated.outcome.ok &&
|
||||
Math.abs(
|
||||
C.Cartographic.fromCartesian(
|
||||
groundSource.importedBillboards.get(0).position,
|
||||
).height - 2000,
|
||||
C.Cartographic.fromCartesian(pins(groundDrawing)[0].position).height -
|
||||
2000,
|
||||
) < 2,
|
||||
);
|
||||
remove(elevated);
|
||||
@@ -526,7 +512,13 @@ try {
|
||||
'the shared clock stays exactly unchanged',
|
||||
C.JulianDate.equals(now, viewer.clock.currentTime),
|
||||
);
|
||||
check('no file sources remain', tool.list().length === 0);
|
||||
check(
|
||||
'no files or drawing sets remain',
|
||||
tool.list().length === 0 &&
|
||||
Object.values(window.__qaImports.list()).every(
|
||||
(list) => list.length === 0,
|
||||
),
|
||||
);
|
||||
return results;
|
||||
});
|
||||
for (const r of results) {
|
||||
|
||||
+99
-112
@@ -1,19 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rendered acceptance for UTILITIES ▸ Import: real files through the real file
|
||||
* input and a real drop, parsed by Cesium in the page.
|
||||
* input and a real drop, drawn through the globe contract.
|
||||
*
|
||||
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
|
||||
* each become one row and one data source; the imported shape actually paints
|
||||
* each become one row and owned drawing sets; the imported shape actually paints
|
||||
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
|
||||
* it) and stops painting when hidden; no import makes the page request
|
||||
* anything named in it (network link, remote icon, remote style, palette
|
||||
* icon, balloon or extended-data HTML, maki marker) or anything outside the
|
||||
* page for it; no entity keeps a description, label or properties; dated KML
|
||||
* page for it; no drawing keeps a description, label or properties; dated KML
|
||||
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
|
||||
* its two segments apart; keyboard focus survives hide/show; a dropped file
|
||||
* imports like a picked one; a file that is not geodata is refused with a
|
||||
* reason and leaves nothing behind; Remove gives every data source back; no
|
||||
* reason and leaves nothing behind; Remove releases every drawing set; no
|
||||
* page errors.
|
||||
*
|
||||
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
|
||||
@@ -23,9 +23,13 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import puppeteer from 'puppeteer';
|
||||
import { installImportProbe } from './qa-geo-import-probe.mjs';
|
||||
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||
|
||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||
const BASE_URL =
|
||||
process.argv.find((arg) => /^https?:/.test(arg)) ||
|
||||
process.env.QA_BASE_URL ||
|
||||
'http://localhost:4173';
|
||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
|
||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
|
||||
@@ -233,7 +237,8 @@ async function cyanPixels() {
|
||||
const state = () =>
|
||||
page.evaluate(() => ({
|
||||
files: window.__gevGeoImport.list(),
|
||||
dataSources: window.__godsEyeView.viewer.dataSources.length,
|
||||
drawing: window.__qaImports.list(),
|
||||
groups: window.__qaImports.groups().length,
|
||||
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
|
||||
hint: document.getElementById('geo-import-hint')?.textContent || '',
|
||||
}));
|
||||
@@ -283,6 +288,7 @@ try {
|
||||
.width > 0,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await installImportProbe(page);
|
||||
const before = await state();
|
||||
check(
|
||||
'Import is in the UTILITIES panel with an empty, hidden list',
|
||||
@@ -313,7 +319,6 @@ try {
|
||||
};
|
||||
});
|
||||
const clockBefore = await clock();
|
||||
const sourcesBefore = before.dataSources;
|
||||
|
||||
// Picked through the real <input type=file>.
|
||||
const input = await page.$('#geo-import-input');
|
||||
@@ -328,10 +333,8 @@ try {
|
||||
);
|
||||
const after = await state();
|
||||
check(
|
||||
'four picked files become four rows and four data sources',
|
||||
imported &&
|
||||
after.rows === 4 &&
|
||||
after.dataSources === before.dataSources + 4,
|
||||
'four picked files become four rows and four groups of drawing sets',
|
||||
imported && after.rows === 4 && after.groups === before.groups + 4,
|
||||
JSON.stringify(
|
||||
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
|
||||
),
|
||||
@@ -361,104 +364,8 @@ try {
|
||||
)),
|
||||
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
|
||||
);
|
||||
const inside = await page.evaluate((from) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const now = viewer.clock.currentTime;
|
||||
const sources = [];
|
||||
for (let i = from; i < viewer.dataSources.length; i++)
|
||||
sources.push(viewer.dataSources.get(i));
|
||||
const entities = sources.flatMap(
|
||||
(ds) => ds.importedEntities || ds.entities.values,
|
||||
);
|
||||
const named = (name) => entities.find((e) => e.name === name);
|
||||
const gpx = sources[3].importedEntities || sources[3].entities.values;
|
||||
const kmz = sources[2].importedEntities || sources[2].entities.values;
|
||||
return {
|
||||
sources: sources.length,
|
||||
clocks: sources.filter((ds) => ds.clock !== undefined).length,
|
||||
described: entities.filter((e) => e.description !== undefined).length,
|
||||
labelled: entities.filter((e) => e.label !== undefined).length,
|
||||
withProperties: entities.filter((e) => e.properties !== undefined).length,
|
||||
withKml: entities.filter((e) => e.kml !== undefined).length,
|
||||
hidden: entities.filter((e) => !e.isAvailable(now)).length,
|
||||
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
|
||||
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
|
||||
named(name)?.billboard?.image?.getValue(now),
|
||||
).length,
|
||||
movingIsLine: Boolean(
|
||||
named('QA moving')?.polyline && !named('QA moving')?.position,
|
||||
),
|
||||
movingVertices:
|
||||
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
|
||||
splitTracks: (sources[1].importedEntities || sources[1].entities.values)
|
||||
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
|
||||
.map((e) => e.polyline.positions.getValue(now).length),
|
||||
gpxLines: gpx.filter((e) => e.polyline).length,
|
||||
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
|
||||
kmzLocalImage: kmz.some((e) =>
|
||||
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
|
||||
),
|
||||
kmzOverlay: kmz.some((e) => e.rectangle),
|
||||
groundedLines: sources
|
||||
.slice(1, 3)
|
||||
.every((ds) =>
|
||||
(ds.importedEntities || ds.entities.values)
|
||||
.filter((e) => e.polyline)
|
||||
.every((e) => e.polyline.clampToGround?.getValue(now)),
|
||||
),
|
||||
xyzPolygon:
|
||||
(sources[0].importedEntities || sources[0].entities.values)
|
||||
.find((e) => e.polygon)
|
||||
?.polygon.perPositionHeight?.getValue(now) !== true,
|
||||
};
|
||||
}, sourcesBefore);
|
||||
check(
|
||||
'no imported entity keeps a description, label, properties or KML data',
|
||||
inside.sources === 4 &&
|
||||
inside.described +
|
||||
inside.labelled +
|
||||
inside.withProperties +
|
||||
inside.withKml ===
|
||||
0,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'no imported source has a clock, and dated features draw now',
|
||||
inside.clocks === 0 &&
|
||||
inside.hidden === 0 &&
|
||||
inside.datedPinNow &&
|
||||
inside.movingIsLine &&
|
||||
inside.movingVertices === 2,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'blocked KML icon sources use drawable local fallback pins',
|
||||
inside.fallbackPins === 2,
|
||||
JSON.stringify({ fallbackPins: inside.fallbackPins }),
|
||||
);
|
||||
check(
|
||||
'KML MultiTrack keeps its segments and original vertex count',
|
||||
inside.splitTracks.join() === '2,2',
|
||||
JSON.stringify(inside.splitTracks),
|
||||
);
|
||||
check(
|
||||
'the GPX track keeps its two segments apart',
|
||||
inside.gpxLines === 2 && inside.gpxPoints === 1,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'KMZ retains its own packed image and overlay',
|
||||
inside.kmzLocalImage && inside.kmzOverlay,
|
||||
);
|
||||
check(
|
||||
'XYZ polygons and KML/KMZ lines use ground geometry',
|
||||
inside.xyzPolygon && inside.groundedLines,
|
||||
);
|
||||
await page.evaluate((from) => {
|
||||
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
|
||||
.entities.values)
|
||||
if (entity.billboard) entity.show = false;
|
||||
}, sourcesBefore);
|
||||
// Over the files: their pins on the ground show unless beyond the
|
||||
// horizon, so all of them show here.
|
||||
await page.evaluate((view) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
@@ -472,6 +379,85 @@ try {
|
||||
});
|
||||
}, VIEW);
|
||||
await wait(2500);
|
||||
const inside = await page.evaluate(() => {
|
||||
const groups = window.__qaImports.groups();
|
||||
const pins = (group) =>
|
||||
group.filter((r) => r.kind === 'movers').flatMap((r) => r.items);
|
||||
const paths = (group) =>
|
||||
group
|
||||
.filter((r) => r.kind === 'ground' && r.options.kind === 'corridor')
|
||||
.flatMap((r) => r.items);
|
||||
const kml = groups[1],
|
||||
kmz = groups[2],
|
||||
gpx = groups[3];
|
||||
const drawings = groups.flat().flatMap((r) => [r.options, ...r.items]);
|
||||
return {
|
||||
groups: groups.length,
|
||||
metadata: drawings.some((r) =>
|
||||
[
|
||||
'description',
|
||||
'label',
|
||||
'properties',
|
||||
'kml',
|
||||
'availability',
|
||||
'clock',
|
||||
].some((key) => key in r),
|
||||
),
|
||||
fallbackPins: pins(kml).length,
|
||||
datedPinsShown:
|
||||
pins(kml).length > 0 && pins(kml).every((pin) => pin.show),
|
||||
kmlVertices: paths(kml).map((line) => line.positions.length),
|
||||
gpxLines: paths(gpx).length,
|
||||
gpxPoints: pins(gpx).length,
|
||||
kmzPins: pins(kmz).length,
|
||||
kmzOverlay: kmz.some(
|
||||
(r) => r.kind === 'imagery' && r.options.source.url.startsWith('blob:'),
|
||||
),
|
||||
groundedLines: [kml, kmz].every(
|
||||
(group) =>
|
||||
paths(group).length === 4 && !group.some((r) => r.kind === 'lines'),
|
||||
),
|
||||
xyzPolygon: groups[0].some(
|
||||
(r) =>
|
||||
r.kind === 'ground' &&
|
||||
r.options.kind === 'polygon' &&
|
||||
r.items[0].rings.flat().every((p) => p.length === 2),
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'import drawing contains no descriptions, labels, properties or time data',
|
||||
inside.groups === 4 && !inside.metadata,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'dated KML pins draw now and its moving track is a two-vertex line',
|
||||
inside.datedPinsShown && inside.kmlVertices[0] === 2,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'blocked KML icons use drawable local fallback pins',
|
||||
inside.fallbackPins === 2,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'KML MultiTrack keeps its segments and original vertex count',
|
||||
inside.kmlVertices.slice(1, 3).join() === '2,2',
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'the GPX track keeps its two segments apart',
|
||||
inside.gpxLines === 2 && inside.gpxPoints === 1,
|
||||
JSON.stringify(inside),
|
||||
);
|
||||
check(
|
||||
'KMZ retains its packed pin and imagery overlay',
|
||||
inside.kmzPins === 2 && inside.kmzOverlay && after.files[2].undrawn === 0,
|
||||
);
|
||||
check(
|
||||
'XYZ polygons and KML/KMZ lines use ground geometry',
|
||||
inside.xyzPolygon && inside.groundedLines,
|
||||
);
|
||||
const cyanShown = await cyanPixels();
|
||||
await shot('imported');
|
||||
check(
|
||||
@@ -574,9 +560,10 @@ try {
|
||||
);
|
||||
const cleared = await state();
|
||||
check(
|
||||
'Remove gives every data source back',
|
||||
cleared.rows === 0 && cleared.dataSources === before.dataSources,
|
||||
`${cleared.dataSources} vs ${before.dataSources}`,
|
||||
'Remove releases every import drawing set',
|
||||
cleared.rows === 0 &&
|
||||
Object.values(cleared.drawing).every((list) => list.length === 0),
|
||||
JSON.stringify(cleared.drawing),
|
||||
);
|
||||
await wait(1500);
|
||||
check(
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['s2-ghostmaps'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
const file = await readFile(
|
||||
new URL('./fixtures/ghostmaps-scenes.kmz', import.meta.url),
|
||||
);
|
||||
await p.evaluateOnNewDocument(
|
||||
(bytes) => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
const events = window.EventSource;
|
||||
window.ghostStats = { streams: 0, reads: 0, catalogs: 0, titles: [] };
|
||||
window.EventSource = class extends EventTarget {
|
||||
constructor(url, options) {
|
||||
super();
|
||||
if (!url.includes('/api/ghostmaps/events'))
|
||||
return new events(url, options);
|
||||
window.ghostStats.streams++;
|
||||
this.readyState = 1;
|
||||
}
|
||||
close() {}
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const { pathname } = new URL(input.url ?? input, document.baseURI);
|
||||
if (pathname === '/api/ghostmaps/catalog') {
|
||||
window.ghostStats.catalogs++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
commit: 'qa',
|
||||
products: [
|
||||
{ id: 'cip', version: 'qa', title: 'QA', defaultOn: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (pathname.startsWith('/api/ghostmaps/file/')) {
|
||||
window.ghostStats.reads++;
|
||||
return Promise.resolve(new Response(new Uint8Array(bytes)));
|
||||
}
|
||||
return fetch(input, options);
|
||||
};
|
||||
const fill = CanvasRenderingContext2D.prototype.fillText;
|
||||
CanvasRenderingContext2D.prototype.fillText = function (value, ...args) {
|
||||
if (/QA (pin|route|zone)/i.test(value))
|
||||
window.ghostStats.titles.push(value);
|
||||
return fill.call(this, value, ...args);
|
||||
};
|
||||
},
|
||||
[...file],
|
||||
);
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('s2-ghostmaps', true, { origin: 'user' });
|
||||
window.qaGhost = dataManager.layers.get('s2-ghostmaps').module;
|
||||
});
|
||||
const click = async (lon, lat) => {
|
||||
const { x, y } = await p.evaluate(
|
||||
(pos) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const point = engine.projector.worldToWindow(pos, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
window.ghostStats.titles = [];
|
||||
return { x: box.left + point.x, y: box.top + point.y };
|
||||
},
|
||||
fromDegrees(lon, lat),
|
||||
);
|
||||
await p.mouse.click(x, y);
|
||||
};
|
||||
const overlay = () =>
|
||||
p.evaluate(() => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return pixels;
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await p.evaluate(() => {
|
||||
window.ghostStats.titles = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(() =>
|
||||
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||
);
|
||||
}
|
||||
const inventory = await p.evaluate(() => ({
|
||||
same:
|
||||
window.qaGhost ===
|
||||
window.__godsEyeView.dataManager.layers.get('s2-ghostmaps').module,
|
||||
names: window.qaGhost.getAnalystRecords().map((r) => r.name),
|
||||
streams: window.ghostStats.streams,
|
||||
reads: window.ghostStats.reads,
|
||||
}));
|
||||
assert.deepEqual(inventory, {
|
||||
same: true,
|
||||
names: ['QA pin', 'QA route', 'QA zone'],
|
||||
streams: 1,
|
||||
reads: 1,
|
||||
});
|
||||
await strict.passed(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
'same model, three features, one event stream, one KMZ read',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2200));
|
||||
for (const [name, lon, lat] of [
|
||||
['QA pin', -79.38, 43.65],
|
||||
['QA route', -79.375, 43.71],
|
||||
['QA zone', -79.28, 43.61],
|
||||
]) {
|
||||
await click(lon, lat);
|
||||
await p.waitForFunction(
|
||||
(name) =>
|
||||
window.ghostStats.titles.some((v) =>
|
||||
v.toLowerCase().includes(name.toLowerCase()),
|
||||
),
|
||||
{ timeout: 12000 },
|
||||
name,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
`a click on ${name} draws its card text`,
|
||||
);
|
||||
console.log('PASS', kind, name, 'pointer hit');
|
||||
}
|
||||
// Not vacuous: the zone's card is up, so the overlay only clears when
|
||||
// the click in the hole is handled as a click on empty ground. The
|
||||
// overlay without any card is measured with a click far from every
|
||||
// feature.
|
||||
const shown = await overlay();
|
||||
await click(-79.316, 43.61);
|
||||
await p.waitForFunction(
|
||||
(shown) => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return pixels < shown - 100;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
shown,
|
||||
);
|
||||
const hole = await overlay();
|
||||
await click(-79.45, 43.55);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
const empty = await overlay();
|
||||
console.log(
|
||||
kind,
|
||||
'overlay pixels: zone card',
|
||||
shown,
|
||||
'hole',
|
||||
hole,
|
||||
'empty ground',
|
||||
empty,
|
||||
);
|
||||
await strict.check(
|
||||
p,
|
||||
's2-ghostmaps',
|
||||
'a click in the zone hole clears the selected zone card like empty ground',
|
||||
shown > empty + 100 && Math.abs(hole - empty) <= 0.02 * shown,
|
||||
{ shown, hole, empty },
|
||||
);
|
||||
await click(-79.28, 43.61);
|
||||
await p.waitForFunction(() =>
|
||||
window.ghostStats.titles.some((v) => /QA zone/i.test(v)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
+93
-58
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* (i) LIVE — enabling the layer loads the default products (Common
|
||||
* Intelligence Picture and the 30-day kinetic pull) from the live
|
||||
* repository through /api/ghostmaps, one data source per product,
|
||||
* repository through /api/ghostmaps, one drawn edition per product
|
||||
* (its globe-contract sets),
|
||||
* matching the proxy's catalog commit; the row shows one chip per
|
||||
* catalog product with the defaults active, and the page holds an
|
||||
* open event stream to /api/ghostmaps/events.
|
||||
@@ -17,12 +18,12 @@
|
||||
* and clicking empty ground clears it.
|
||||
* (iii) PUSH (synthetic) — the event stream is replaced in the page and a
|
||||
* catalog naming a new edition of one product is pushed: only that
|
||||
* product reloads (a new data source replaces the old one, no
|
||||
* duplicates), the other keeps its data source. A catalog without a
|
||||
* product reloads (a new edition replaces the old one, no
|
||||
* duplicates), the other keeps its edition. A catalog without a
|
||||
* product removes it.
|
||||
* (iv) CHIPS — clicking an active product chip removes that product,
|
||||
* clicking it again restores it; the choice survives a reload.
|
||||
* (v) DISABLE — every GhostMaps data source leaves the viewer.
|
||||
* (v) DISABLE — every GhostMaps set leaves the globe.
|
||||
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips
|
||||
* fit the panel width with no horizontal overflow.
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
@@ -78,15 +79,63 @@ async function boot(page) {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/** GhostMaps data sources on the viewer: name → entity count. */
|
||||
/**
|
||||
* GhostMaps product editions on the globe contract: one row per drawn
|
||||
* edition, as one data source per product used to be. The display
|
||||
* (render/display.js) gives each edition three sets named
|
||||
* `${prefix}${productId}:${edition}:{polygons,lines,pins}`; `count` sums their
|
||||
* items.
|
||||
*/
|
||||
function ghostSources(prefix) {
|
||||
const { dataSources } = window.__godsEyeView.viewer;
|
||||
const out = [];
|
||||
for (let i = 0; i < dataSources.length; i++) {
|
||||
const ds = dataSources.get(i);
|
||||
if (ds.name?.startsWith(prefix))
|
||||
out.push({ name: ds.name, count: ds.entities.values.length });
|
||||
const { engine } = window.__godsEyeView;
|
||||
const editions = new Map();
|
||||
for (const set of [...engine.ground.list(), ...engine.movers.list()]) {
|
||||
if (!set.id.startsWith(prefix)) continue;
|
||||
const [productId, edition] = set.id.slice(prefix.length).split(':');
|
||||
const key = `${productId}:${edition}`;
|
||||
const row = editions.get(key) || {
|
||||
name: `${prefix}${productId}`,
|
||||
edition: Number(edition),
|
||||
count: 0,
|
||||
};
|
||||
row.count += set.count;
|
||||
editions.set(key, row);
|
||||
}
|
||||
return [...editions.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn placemarks of the GhostMaps sprite sets (`...:pins`), from the
|
||||
* Cesium engine's own billboards for those sets (the contract has no
|
||||
* per-instance read-back): { id, show, position } each.
|
||||
*/
|
||||
function ghostPins(prefix) {
|
||||
const gev = window.__godsEyeView;
|
||||
const counts = new Map(
|
||||
gev.engine.movers.list().map((set) => [set.id, set.count]),
|
||||
);
|
||||
const out = [];
|
||||
const walk = (collection, depth) => {
|
||||
for (let i = 0; i < (collection?.length || 0); i++) {
|
||||
const primitive = collection.get(i);
|
||||
const setId = primitive?.gevMoverSet;
|
||||
if (typeof setId === 'string') {
|
||||
if (!setId.startsWith(prefix) || !setId.endsWith(':pins')) continue;
|
||||
const count = Math.min(counts.get(setId) ?? 0, primitive.length);
|
||||
for (let j = 0; j < count; j++) {
|
||||
const billboard = primitive.get(j);
|
||||
out.push({
|
||||
set: setId,
|
||||
id: String(billboard.id),
|
||||
show: billboard.show,
|
||||
position: billboard.position,
|
||||
});
|
||||
}
|
||||
} else if (depth < 3 && typeof primitive?.get === 'function')
|
||||
walk(primitive, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(gev.viewer.scene.primitives, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -127,6 +176,9 @@ const stats = (page) =>
|
||||
);
|
||||
const sources = (page) =>
|
||||
page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`);
|
||||
// Installs ghostPins() in the page (the evaluate callbacks below call it).
|
||||
const installPins = (page) =>
|
||||
page.evaluate(`window.__qaGhostPins = ${ghostPins}`);
|
||||
|
||||
async function showRow(page) {
|
||||
await page.evaluate((id) => {
|
||||
@@ -298,14 +350,12 @@ async function main() {
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
await installPins(page);
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ds = [...Array(gev.viewer.dataSources.length).keys()]
|
||||
.map((i) => gev.viewer.dataSources.get(i))
|
||||
.find((item) => item.name === `${id}:cip`);
|
||||
const entity = ds.entities.values.find((e) => e.billboard);
|
||||
const pin = window.__qaGhostPins(`${id}:cip:`)[0];
|
||||
const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic(
|
||||
entity.position.getValue(gev.viewer.clock.currentTime),
|
||||
pin.position,
|
||||
);
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
@@ -321,7 +371,7 @@ async function main() {
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
return entity.id;
|
||||
return pin.id;
|
||||
}, LAYER_ID);
|
||||
await page
|
||||
.waitForFunction(
|
||||
@@ -333,24 +383,22 @@ async function main() {
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const { viewer: v, engine } = window.__godsEyeView;
|
||||
v.scene.requestRender?.();
|
||||
const c = v.scene.canvas;
|
||||
return v.scene
|
||||
return engine.picker
|
||||
.drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8)
|
||||
.some((p) =>
|
||||
String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'),
|
||||
);
|
||||
.some((p) => String(p?.id ?? '').startsWith('ghostmaps:cip:'));
|
||||
},
|
||||
{ timeout: 15000, polling: 500 },
|
||||
)
|
||||
.catch(() => {});
|
||||
const probe = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
const { viewer: v, engine } = window.__godsEyeView;
|
||||
const c = v.scene.canvas;
|
||||
const box = c.getBoundingClientRect();
|
||||
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||
const picked = v.scene.drillPick(centre, 8);
|
||||
const picked = engine.picker.drillPick(centre, 8);
|
||||
// Empty ground: the first point below the marker (the card is drawn
|
||||
// above it) that picks nothing at all; markers can be dense in a city.
|
||||
let empty = null;
|
||||
@@ -360,14 +408,14 @@ async function main() {
|
||||
x: centre.x + r * Math.cos((a * Math.PI) / 4),
|
||||
y: centre.y + r * Math.sin((a * Math.PI) / 4),
|
||||
};
|
||||
if (!v.scene.pick(p)) {
|
||||
if (engine.picker.pick(p)?.id == null) {
|
||||
empty = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
empty ||= { x: centre.x + 400, y: centre.y + 300 };
|
||||
return {
|
||||
ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')),
|
||||
ids: picked.map((p) => String(p?.id ?? '')),
|
||||
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||
empty: { x: empty.x + box.left, y: empty.y + box.top },
|
||||
};
|
||||
@@ -462,23 +510,18 @@ async function main() {
|
||||
const norm = (v) => Math.hypot(v.x, v.y, v.z);
|
||||
let far = 0;
|
||||
let nearShown = 0;
|
||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||
const ds = gev.viewer.dataSources.get(i);
|
||||
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||
for (const e of ds.entities.values) {
|
||||
if (!e.billboard) continue;
|
||||
const p = e.position.getValue(gev.viewer.clock.currentTime);
|
||||
// Angle from the camera's nadir to the pin, against the
|
||||
// geometric horizon angle; the cull's 1 km margin shows pins up
|
||||
// to about 1° past it, so "clearly behind" is beyond 2°.
|
||||
const angle = Math.acos(
|
||||
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
|
||||
);
|
||||
const horizon = Math.acos(norm(p) / norm(c));
|
||||
const margin = (2 * Math.PI) / 180;
|
||||
if (e.show && angle > horizon + margin) far++;
|
||||
if (e.show && angle < horizon - margin) nearShown++;
|
||||
}
|
||||
for (const e of window.__qaGhostPins(`${id}:`)) {
|
||||
const p = e.position;
|
||||
// Angle from the camera's nadir to the pin, against the
|
||||
// geometric horizon angle; the cull's 1 km margin shows pins up
|
||||
// to about 1° past it, so "clearly behind" is beyond 2°.
|
||||
const angle = Math.acos(
|
||||
(p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)),
|
||||
);
|
||||
const horizon = Math.acos(norm(p) / norm(c));
|
||||
const margin = (2 * Math.PI) / 180;
|
||||
if (e.show && angle > horizon + margin) far++;
|
||||
if (e.show && angle < horizon - margin) nearShown++;
|
||||
}
|
||||
return { far, nearShown };
|
||||
},
|
||||
@@ -605,21 +648,13 @@ async function main() {
|
||||
});
|
||||
await boot(push);
|
||||
await enableAndSettle(push);
|
||||
const identity = () =>
|
||||
push.evaluate((id) => {
|
||||
const { dataSources } = window.__godsEyeView.viewer;
|
||||
window.__qaIds ||= new WeakMap();
|
||||
window.__qaNext ||= 1;
|
||||
const out = {};
|
||||
for (let i = 0; i < dataSources.length; i++) {
|
||||
const ds = dataSources.get(i);
|
||||
if (!ds.name?.startsWith(`${id}:`)) continue;
|
||||
if (!window.__qaIds.has(ds))
|
||||
window.__qaIds.set(ds, window.__qaNext++);
|
||||
out[ds.name] = window.__qaIds.get(ds);
|
||||
}
|
||||
return out;
|
||||
}, LAYER_ID);
|
||||
// Each product's drawn edition (its sets' sequence number): a reload
|
||||
// draws a new edition in place of the old one.
|
||||
const identity = async () => {
|
||||
const out = {};
|
||||
for (const row of await sources(push)) out[row.name] = row.edition;
|
||||
return out;
|
||||
};
|
||||
const first = await identity();
|
||||
const pushed = Date.now();
|
||||
await push.evaluate(async (id) => {
|
||||
|
||||
+44
-24
@@ -24,7 +24,7 @@
|
||||
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
|
||||
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
|
||||
* cells already on the globe are kept.
|
||||
* (iv) DISABLE — owned ground primitives are hidden.
|
||||
* (iv) DISABLE — the layer's engine.ground cell set is hidden.
|
||||
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
|
||||
* with Enter and off with Space, keeps focus and names its state.
|
||||
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
|
||||
@@ -129,18 +129,18 @@ async function enableAndSettle(page, { refresh = false } = {}) {
|
||||
const rowName =
|
||||
document.querySelector(`[data-layer-id="${id}"] .data-name`)
|
||||
?.textContent ?? '';
|
||||
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
// The layer's cells are one engine.ground cell set named after it.
|
||||
const set = window.__godsEyeView.engine.ground
|
||||
.list()
|
||||
.find((item) => item.id === id);
|
||||
return {
|
||||
stats,
|
||||
legend,
|
||||
blurb,
|
||||
rowName,
|
||||
name: layer.name,
|
||||
entities: ds?.get(0)?.geometryInstances.length ?? 0,
|
||||
shown: ds?.show ?? null,
|
||||
entities: set?.count ?? 0,
|
||||
shown: set?.shown ?? null,
|
||||
};
|
||||
},
|
||||
LAYER_ID,
|
||||
@@ -151,6 +151,16 @@ async function enableAndSettle(page, { refresh = false } = {}) {
|
||||
/** Open the Data Layers panel and bring the GNSS row into view. */
|
||||
async function showLayerRow(page) {
|
||||
await page.evaluate((id) => {
|
||||
// A phone viewport shows the panel in the shell's Layers sheet
|
||||
// (src/ui/mobileShell.js), opened from its tab bar.
|
||||
const layersTab = document.querySelector(
|
||||
'#mobile-tab-bar [data-mobile-sheet="layers"]',
|
||||
);
|
||||
if (
|
||||
layersTab?.offsetParent &&
|
||||
!document.body.matches('[data-mobile-sheet="layers"]')
|
||||
)
|
||||
layersTab.click();
|
||||
const panel = document.getElementById('data-panel');
|
||||
if (panel.classList.contains('collapsed'))
|
||||
panel.querySelector('[data-collapse-target="data-panel"]').click();
|
||||
@@ -184,15 +194,25 @@ async function shoot(page, name) {
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
|
||||
}
|
||||
|
||||
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
|
||||
/**
|
||||
* Build state of the GNSS ground set's Cesium batches (DONE = 0, PENDING = 1):
|
||||
* the primitives the Cesium ground engine tags with the set id, under
|
||||
* scene.groundPrimitives. Empty while the set has nothing committed.
|
||||
*/
|
||||
function cellStates(id) {
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const all = viewer.scene.groundPrimitives;
|
||||
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
if (!ds?.length) return [];
|
||||
return [ds.get(0).ready ? 0 : 1];
|
||||
const { viewer, engine } = window.__godsEyeView;
|
||||
const set = engine.ground.list().find((item) => item.id === id);
|
||||
if (!set?.count) return [];
|
||||
const states = [];
|
||||
const walk = (collection) => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (item?.gevGroundSet === id) states.push(item.ready ? 0 : 1);
|
||||
else if (typeof item?.get === 'function' && 'length' in item) walk(item);
|
||||
}
|
||||
};
|
||||
walk(viewer.scene.groundPrimitives);
|
||||
return states.length ? states : [1];
|
||||
}
|
||||
|
||||
/** Wait until every GNSS cell's ground rectangle has finished building. */
|
||||
@@ -557,8 +577,9 @@ async function main() {
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const v = window.__godsEyeView.viewer,
|
||||
p = v.scene.pick({
|
||||
// The engine's pick names the perimeter ground item's pickId.
|
||||
const { viewer: v, engine } = window.__godsEyeView,
|
||||
p = engine.picker.pick({
|
||||
x: v.scene.canvas.clientWidth / 2,
|
||||
y: v.scene.canvas.clientHeight / 2,
|
||||
});
|
||||
@@ -569,11 +590,11 @@ async function main() {
|
||||
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||
);
|
||||
const pickProbe = await page.evaluate(() => {
|
||||
const v = window.__godsEyeView.viewer,
|
||||
const { viewer: v, engine } = window.__godsEyeView,
|
||||
c = v.scene.canvas;
|
||||
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||
const empty = { x: centre.x + 300, y: centre.y };
|
||||
const picked = v.scene.pick(empty),
|
||||
const picked = engine.picker.pick(empty),
|
||||
box = c.getBoundingClientRect();
|
||||
return {
|
||||
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||
@@ -778,12 +799,11 @@ async function main() {
|
||||
console.log('\n(iv) DISABLE...');
|
||||
const shown = await page.evaluate(async (id) => {
|
||||
await window.__godsEyeView.dataManager.setEnabled(id, false);
|
||||
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||
(item) => item.id === id,
|
||||
)?.show;
|
||||
return window.__godsEyeView.engine.ground
|
||||
.list()
|
||||
.find((item) => item.id === id)?.shown;
|
||||
}, LAYER_ID);
|
||||
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
|
||||
record('DISABLE: ground set hidden', shown === false, `show=${shown}`);
|
||||
await shoot(page, 'disabled');
|
||||
|
||||
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import sharp from 'sharp';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
clickEmptyGround,
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForCameraAtRest,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=43.6500&lon=-79.3800&alt=46000&heading=0&pitch=-90&roll=0&map=sentinel2&l=4';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [
|
||||
'ais-live-vessels',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'fire-perimeters',
|
||||
],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.fetch = (input, o) => {
|
||||
const url = new URL(input.url ?? input, document.baseURI);
|
||||
if (url.pathname === '/api/vessels/track') {
|
||||
window.qaVesselHistories = (window.qaVesselHistories ?? 0) + 1;
|
||||
return Promise.resolve(
|
||||
Response.json({ samples: [{ lat: 43.63, lon: -79.38 }] }),
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/vessels') {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
status: 'live',
|
||||
rows: [
|
||||
{
|
||||
mmsi: '123456789',
|
||||
name: 'Toronto QA',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
last_position_epoch: Date.now() / 1000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
|
||||
return Promise.resolve(Response.json({ rows: [] }));
|
||||
}
|
||||
if (url.pathname === '/api/fire-perimeters') {
|
||||
const square = (dx) => [
|
||||
[-79.38 - dx, 43.65 - dx],
|
||||
[-79.38 + dx, 43.65 - dx],
|
||||
[-79.38 + dx, 43.65 + dx],
|
||||
[-79.38 - dx, 43.65 + dx],
|
||||
[-79.38 - dx, 43.65 - dx],
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'fire:fixture',
|
||||
name: 'QA Fire',
|
||||
acres: 500,
|
||||
containedPct: 40,
|
||||
polygons: [[square(0.09), square(0.018).reverse()]],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.pathname === '/api/gnss-integrity') {
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
ageMs: 0,
|
||||
rows: ['a1', 'a2', 'a3'].map((hex) => ({
|
||||
hex,
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
nic: 0,
|
||||
nacp: 0,
|
||||
degraded: true,
|
||||
gpsLost: false,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.hostname === 'api.weather.gc.ca') {
|
||||
const records = url.pathname.includes('aqhi-stations')
|
||||
? [
|
||||
{
|
||||
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
location_name_en: 'QA Toronto',
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
properties: {
|
||||
location_id: 'QA1',
|
||||
aqhi: 9,
|
||||
observation_datetime: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
];
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: records.map((f) => ({ type: 'Feature', ...f })),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fetch(input, o);
|
||||
};
|
||||
});
|
||||
await p.goto(base.href);
|
||||
const capture = async (visible, layerId = 'air-quality') => {
|
||||
await p.evaluate(
|
||||
async (show, id) => {
|
||||
const d = window.__godsEyeView;
|
||||
await d.dataManager.setEnabled(id, show, { origin: 'user' });
|
||||
d.engine.render.request('qa-air-quality');
|
||||
},
|
||||
visible,
|
||||
layerId,
|
||||
);
|
||||
// Ready to compare: the layer's data is in (when shown), the globe's
|
||||
// tiles under the view have loaded and stayed loaded, and a frame has
|
||||
// been drawn since.
|
||||
await p
|
||||
.waitForFunction(
|
||||
(show, id) => {
|
||||
const d = window.__godsEyeView;
|
||||
if (!d?.engine) return false;
|
||||
const module = d.dataManager.layers.get(id).module;
|
||||
const data =
|
||||
!show ||
|
||||
(id === 'air-quality'
|
||||
? module.getAnalystRecords().length === 1
|
||||
: module.getStats().levels?.high === 1);
|
||||
const now = performance.now();
|
||||
// The engines draw on request: keep frames coming so tiles load.
|
||||
d.engine.render.request('qa-ground-settle');
|
||||
if (!data || !d.engine.surface.tilesLoaded()) {
|
||||
window.__qaSettledSince = null;
|
||||
return false;
|
||||
}
|
||||
window.__qaSettledSince ??= now;
|
||||
return now - window.__qaSettledSince > 1200;
|
||||
},
|
||||
{ polling: 100, timeout: 30000 },
|
||||
visible,
|
||||
layerId,
|
||||
)
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'not settled',
|
||||
JSON.stringify(
|
||||
await p.evaluate((id) => {
|
||||
const d = window.__godsEyeView;
|
||||
const module = d?.dataManager.layers.get(id).module;
|
||||
return {
|
||||
engine: d?.engine?.kind,
|
||||
tiles: d?.engine?.surface.tilesLoaded(),
|
||||
records: module?.getAnalystRecords().length,
|
||||
stats: module?.getStats(),
|
||||
};
|
||||
}, layerId),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
// A frame drawn after the settle (the engine draws on request), then
|
||||
// the animation frame that presents it.
|
||||
await p.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
remove();
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
engine.render.request('qa-ground');
|
||||
}),
|
||||
);
|
||||
await p.evaluate(() => (window.__qaSettledSince = null));
|
||||
return sample();
|
||||
};
|
||||
const sample = async () => {
|
||||
const shot = await p.screenshot();
|
||||
return sharp(shot)
|
||||
.extract({ left: 537, top: 403, width: 22, height: 18 })
|
||||
.removeAlpha()
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((buf) => sharp(buf).stats());
|
||||
};
|
||||
// The layer shown, sampled until its polygon appears in the patch (the
|
||||
// engines draw a clamped polygon a few frames after its data, with no
|
||||
// signal for it), for at most 20 s; the comparison stays the same.
|
||||
const orangeOf = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||
const drawn = async (off, layerId) => {
|
||||
const deadline = Date.now() + 20000;
|
||||
let on = await capture(true, layerId);
|
||||
while (orangeOf(on) <= orangeOf(off) + 5 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
on = await sample();
|
||||
}
|
||||
return on;
|
||||
};
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(() => {
|
||||
const d = window.__godsEyeView;
|
||||
|
||||
window.qaAir = d.dataManager.layers.get('air-quality').module;
|
||||
window.qaGnss = d.dataManager.layers.get('gnss-integrity').module;
|
||||
});
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
const same = await p.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager.layers.get('air-quality').module ===
|
||||
window.qaAir &&
|
||||
window.__godsEyeView.dataManager.layers.get('gnss-integrity')
|
||||
.module === window.qaGnss,
|
||||
);
|
||||
await strict.check(
|
||||
p,
|
||||
['air-quality', 'gnss-integrity'],
|
||||
'same layer models after the switch',
|
||||
same,
|
||||
);
|
||||
}
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('gnss-integrity', false),
|
||||
);
|
||||
const off = await capture(false),
|
||||
on = await drawn(off, 'air-quality');
|
||||
const values = await p.evaluate(() => ({
|
||||
records: window.qaAir.getAnalystRecords(),
|
||||
card: window.qaAir.getRowControls().list.items,
|
||||
stats: window.qaAir.getStats(),
|
||||
})); // An interior patch away from the label must take on the high-risk orange.
|
||||
const orange = (v) => v.channels[0].mean - v.channels[2].mean;
|
||||
assert.ok(
|
||||
orange(on) > orange(off) + 5,
|
||||
kind + ' must draw the station footprint',
|
||||
);
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
values.records[0].id,
|
||||
'AQHI',
|
||||
values.records[0].value,
|
||||
);
|
||||
assert.equal(values.records[0].name, 'QA Toronto');
|
||||
assert.equal(values.records[0].value, 9);
|
||||
assert.equal(values.card.length, 1);
|
||||
await strict.passed(
|
||||
p,
|
||||
'air-quality',
|
||||
'AQHI 9 station footprint drawn, one card row',
|
||||
{
|
||||
off: orange(off),
|
||||
on: orange(on),
|
||||
},
|
||||
);
|
||||
await capture(false);
|
||||
const cellOff = await capture(false, 'gnss-integrity');
|
||||
const cellOn = await drawn(cellOff, 'gnss-integrity');
|
||||
assert.ok(
|
||||
orange(cellOn) > orange(cellOff) + 5,
|
||||
kind + ' must draw GNSS high cell',
|
||||
);
|
||||
const gnss = await p.evaluate(() => window.qaGnss.getStats());
|
||||
assert.equal(gnss.levels.high, 1);
|
||||
await strict.passed(p, 'gnss-integrity', 'one high GNSS cell drawn', {
|
||||
off: orange(cellOff),
|
||||
on: orange(cellOn),
|
||||
});
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'GNSS high cell, view anchor',
|
||||
gnss.coverage.latitude,
|
||||
gnss.coverage.longitude,
|
||||
);
|
||||
await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('air-quality', true),
|
||||
);
|
||||
}
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
await dataManager.setEnabled('air-quality', false);
|
||||
await dataManager.setEnabled('fire-perimeters', true, { origin: 'user' });
|
||||
window.qaFire = dataManager.layers.get('fire-perimeters').module;
|
||||
if (
|
||||
!dataManager.isEnabled('fire-perimeters') ||
|
||||
window.qaFire.getStats().count !== 1
|
||||
)
|
||||
throw Error(
|
||||
'Failed to enable fire perimeter fixture: ' +
|
||||
JSON.stringify(window.qaFire.getStats()),
|
||||
);
|
||||
});
|
||||
const card = async (visible) =>
|
||||
p.waitForFunction(
|
||||
(want) => {
|
||||
const canvas = document.getElementById('world-overlay-canvas');
|
||||
const data = canvas
|
||||
.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let pixels = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i]) pixels++;
|
||||
return want ? pixels > 100 : pixels === 0;
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
visible,
|
||||
);
|
||||
const click = async (lon, lat) => {
|
||||
const { x, y } = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const point = engine.projector.worldToWindow(world, {});
|
||||
if (!point) throw Error('the point is not in view');
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: box.left + point.x, y: box.top + point.y };
|
||||
},
|
||||
fromDegrees(lon, lat),
|
||||
);
|
||||
await p.mouse.click(x, y);
|
||||
};
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
await card(true);
|
||||
await strict.check(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'same model; the incident card survives the switch',
|
||||
await p.evaluate(
|
||||
() =>
|
||||
window.qaFire ===
|
||||
window.__godsEyeView.dataManager.layers.get('fire-perimeters')
|
||||
.module,
|
||||
),
|
||||
);
|
||||
}
|
||||
await click(-79.51, 43.65);
|
||||
await card(false);
|
||||
await strict.passed(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click outside the perimeter clears the card',
|
||||
);
|
||||
await click(-79.435, 43.65);
|
||||
await card(true);
|
||||
await strict.check(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click on the ring selects the fire',
|
||||
await p.evaluate(() => window.qaFire.getSelectedInfo?.()?.id ?? true),
|
||||
);
|
||||
// Not vacuous: the fire is selected, so the card only goes when the
|
||||
// click in the hole is handled as a click outside the polygon.
|
||||
await click(-79.38, 43.65);
|
||||
await card(false);
|
||||
await strict.passed(
|
||||
p,
|
||||
'fire-perimeters',
|
||||
'a click in the hole deselects the selected fire',
|
||||
);
|
||||
await click(-79.435, 43.65);
|
||||
await card(true);
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'select fire under GNSS, exclude hole, retain incident card',
|
||||
);
|
||||
}
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
for (const id of ['gnss-integrity', 'fire-perimeters', 'air-quality'])
|
||||
await dataManager.setEnabled(id, false);
|
||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||
window.qaVesselEntry = dataManager.layers.get('ais-live-vessels');
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.qaDrawn()?.chevrons[0]?.show,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
// Click-picking on the running engine: clear the selection with a click on
|
||||
// empty water, then select the vessel with a click on its chevron.
|
||||
const pickVessel = async () => {
|
||||
// The last selection flies the camera to the vessel, and a click does
|
||||
// not cut that flight short: click once it has landed.
|
||||
await waitForCameraAtRest(p);
|
||||
// Empty ground: a point in the middle of the view where the engine
|
||||
// picks nothing and nothing covers the canvas.
|
||||
await clickEmptyGround(p, {
|
||||
left: 300,
|
||||
top: 220,
|
||||
right: 720,
|
||||
bottom: 560,
|
||||
step: 11,
|
||||
radius: 10,
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => !window.qaVesselEntry.module.getSelectedInfo(),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await click(-79.38, 43.65);
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'a click on the chevron selects the vessel',
|
||||
);
|
||||
// The next switch starts from the landed view.
|
||||
await waitForCameraAtRest(p);
|
||||
};
|
||||
await click(-79.38, 43.65);
|
||||
await p.waitForFunction(
|
||||
() => window.qaVesselEntry.module.getSelectedInfo()?.mmsi === '123456789',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'a click on the chevron selects the vessel',
|
||||
);
|
||||
let histories = await p.evaluate(() => window.qaVesselHistories);
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await strict.switchTo(p, kind);
|
||||
}
|
||||
const sample = await p.evaluate(() => {
|
||||
const entry =
|
||||
window.__godsEyeView.dataManager.layers.get('ais-live-vessels');
|
||||
const drawn = entry.module.qaDrawn();
|
||||
return {
|
||||
retained: entry === window.qaVesselEntry,
|
||||
selected: entry.module.getSelectedInfo()?.mmsi,
|
||||
chevron: drawn.chevrons.find((c) => c.selected && c.show)?.id,
|
||||
card: drawn.cards.find((c) => c.selected)?.id,
|
||||
histories: window.qaVesselHistories,
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
sample,
|
||||
{
|
||||
retained: true,
|
||||
selected: '123456789',
|
||||
chevron: '123456789',
|
||||
card: 'vessel:123456789',
|
||||
histories,
|
||||
},
|
||||
kind,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'ais-live-vessels',
|
||||
'same entry; selection, chevron and card kept; no new track request',
|
||||
);
|
||||
if (kind === 'cesium' || histories === 1) {
|
||||
await pickVessel();
|
||||
histories = await p.evaluate(() => window.qaVesselHistories);
|
||||
}
|
||||
console.log(
|
||||
'PASS',
|
||||
kind,
|
||||
'retained selected AIS vessel, chevron, card and single track request',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Navara ports for the existing heading scenarios. No app viewer is installed.
|
||||
// Samples come from the renderer handles through the read-only layer probe.
|
||||
export async function installNavaraHeadingProbe(page) {
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
if (app.engine.kind !== 'navara') return;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const engine = app.engine;
|
||||
const drawn = (id) =>
|
||||
['flights', 'military']
|
||||
.flatMap((layer) => app.layers.drawn(layer) ?? [])
|
||||
.filter((row) => row.id === id);
|
||||
window.__findTrackedModel = (id) => {
|
||||
const rows = drawn(id);
|
||||
const row =
|
||||
rows.find((r) => r.trackedModel && r.model?.ready && r.model.show) ??
|
||||
rows.find((r) => r.model?.ready && r.model.show);
|
||||
return row
|
||||
? {
|
||||
id,
|
||||
ready: row.model.ready,
|
||||
show: row.model.show,
|
||||
modelMatrix: row.model.matrix,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
window.__courseFromModelMatrix = (mm, offset) => {
|
||||
if (!mm) return null;
|
||||
const c = geo.cartographic.fromCartesian({
|
||||
x: mm[12],
|
||||
y: mm[13],
|
||||
z: mm[14],
|
||||
});
|
||||
const ex = -Math.sin(c.longitude),
|
||||
ey = Math.cos(c.longitude);
|
||||
const nx = -Math.sin(c.latitude) * Math.cos(c.longitude);
|
||||
const ny = -Math.sin(c.latitude) * Math.sin(c.longitude),
|
||||
nz = Math.cos(c.latitude);
|
||||
const h =
|
||||
(Math.atan2(
|
||||
-(mm[0] * nx + mm[1] * ny + mm[2] * nz),
|
||||
mm[0] * ex + mm[1] * ey,
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
return (((h - offset) % 360) + 360) % 360;
|
||||
};
|
||||
const turn = (angle = 0, nearer = 0) => {
|
||||
const handle = engine.camera.following();
|
||||
const target = handle?.target();
|
||||
if (!target)
|
||||
throw new Error('Heading screenshot needs a followed target');
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(target);
|
||||
const inverse = geo.matrix4.inverseTransformation(
|
||||
frame,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const p = geo.matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
engine.camera.pose().position,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const x = p.x * Math.cos(angle) - p.y * Math.sin(angle);
|
||||
const y = p.x * Math.sin(angle) + p.y * Math.cos(angle);
|
||||
const range = Math.hypot(x, y, p.z);
|
||||
const scale = Math.max(150, range - nearer) / range;
|
||||
handle.retarget({
|
||||
viewFrom: { x: x * scale, y: y * scale, z: p.z * scale },
|
||||
});
|
||||
};
|
||||
window.__headingView = {
|
||||
clock: { currentTime: undefined },
|
||||
get trackedEntity() {
|
||||
const handle = engine.camera.following();
|
||||
if (!handle) return null;
|
||||
const label = handle.label;
|
||||
const id = label?.id?.split(':').at(-1);
|
||||
return {
|
||||
gevTrackedId: label?.id,
|
||||
gevLabelModel:
|
||||
typeof label?.model === 'function' ? label.model() : label?.model,
|
||||
gevDisplayPosition: handle.target,
|
||||
billboard: {
|
||||
rotation: {
|
||||
getValue: () => drawn(id).find((r) => r.tracked)?.trackedRotation,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
camera: {
|
||||
rotateRight: (a) => turn(a),
|
||||
rotateLeft: (a) => turn(-a),
|
||||
zoomIn: (m) => turn(0, m),
|
||||
},
|
||||
scene: {
|
||||
preRender: engine.frames.preRender,
|
||||
postRender: engine.frames.postRender,
|
||||
requestRender: () => engine.render.request('heading-probe'),
|
||||
},
|
||||
};
|
||||
// Observe real Navara frames; unlike Cesium this port does not own a
|
||||
// manual viewer loop. The 600 ms case samples at that cadence while
|
||||
// Navara continues rendering; the dt-clamp branch is covered by unit QA.
|
||||
window.__sampleModelCourse = (id, offset, windowMs, stepMs = 100) =>
|
||||
new Promise((resolve) => {
|
||||
const samples = [];
|
||||
let last = -Infinity;
|
||||
const remove = engine.frames.preRender.addEventListener(() => {
|
||||
const now = performance.now();
|
||||
if (now - last < stepMs) return;
|
||||
last = now;
|
||||
const model = window.__findTrackedModel(id);
|
||||
samples.push({
|
||||
tMs: now,
|
||||
epochMs: Date.now(),
|
||||
course: model
|
||||
? window.__courseFromModelMatrix(model.modelMatrix, offset)
|
||||
: null,
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
remove();
|
||||
resolve(samples);
|
||||
}, windowMs);
|
||||
engine.render.request('heading-probe');
|
||||
});
|
||||
});
|
||||
}
|
||||
+59
-22
@@ -169,29 +169,36 @@ function waitForTilesLoaded(page, timeoutMs = 15000) {
|
||||
|
||||
/**
|
||||
* Reads a camera's placed mount-point cartographic height off its
|
||||
* `cctv-<id>-ray-tl` polyline entity (first position = the mount, per
|
||||
* cctv.js `buildCoverageEntities`/`applyFrustumGeometry`), returns
|
||||
* `{ mountAltM, groundM }` where `groundM = mountAltM - mountHeightM` — the
|
||||
* SAME arithmetic cctv.js itself uses in reverse
|
||||
* (`mountAlt = ground + mountHeightM` in `computeFrustumGeometry`).
|
||||
* `cctv-<id>-ray-tl` corner ray (first position = the mount, per the CCTV
|
||||
* geometry's `applyFrustumGeometry`), returns `{ mountAltM, groundM }` where
|
||||
* `groundM = mountAltM - mountHeightM` — the SAME arithmetic the layer itself
|
||||
* uses in reverse (`mountAlt = ground + mountHeightM` in
|
||||
* `computeFrustumGeometry`). The ray is an engine line on the globe contract
|
||||
* (src/layers/cctv/render/display.js): it must be live in
|
||||
* `engine.lines.list()`, and its positions are the ones the layer's line
|
||||
* handle (record.coverageEntities) gave it.
|
||||
* @param {import('puppeteer').Page} page
|
||||
* @param {string} camId
|
||||
* @returns {Promise<{mountAltM:number, mountHeightM:number, groundM:number}|null>}
|
||||
*/
|
||||
async function readCameraGround(page, camId) {
|
||||
return page.evaluate((id) => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const time = viewer.clock.currentTime;
|
||||
const ent = viewer.entities.getById(`cctv-${id}-ray-tl`);
|
||||
if (!ent || !ent.polyline) return null;
|
||||
const positions = ent.polyline.positions.getValue(time);
|
||||
if (!Array.isArray(positions) || positions.length < 1) return null;
|
||||
const { viewer, engine, dataManager } = window.__godsEyeView;
|
||||
const mod = dataManager.layers.get('cctv').module;
|
||||
const lineId = `cctv-${id}-ray-tl`;
|
||||
const line = engine.lines.list().find((entry) => entry.id === lineId);
|
||||
const handle = mod
|
||||
._cctvRecordsForTest()
|
||||
.find((record) => record.camera?.id === id)
|
||||
?.coverageEntities?.find((entry) => entry?.id === lineId);
|
||||
const positions = handle?.polyline?.positions;
|
||||
if (!line || !Array.isArray(positions) || positions.length < 1) return null;
|
||||
if (line.points !== positions.length) return null;
|
||||
const mount = positions[0];
|
||||
// No window.Cesium global (qa-cctv-v2 precedent) — borrow Cartographic's
|
||||
// static fromCartesian off a live Cartographic instance's constructor.
|
||||
// No window.Cesium global (qa-cctv-v2 precedent): the ellipsoid's own
|
||||
// cartesianToCartographic reads the plain { x, y, z }.
|
||||
const carto = viewer.scene.globe.ellipsoid.cartesianToCartographic(mount);
|
||||
const mountAltM = carto.height;
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
const cam = mod.getUIState().cameras.find((c) => c.id === id);
|
||||
const mountHeightM = cam ? cam.mountHeightM : null;
|
||||
return {
|
||||
@@ -213,7 +220,8 @@ async function readCameraGround(page, camId) {
|
||||
* @returns {Promise<number|null>} ellipsoid height in metres, or null on failure.
|
||||
*/
|
||||
async function fetchReearthEllipsoid(lat, lon) {
|
||||
const url = `${APP_URL}/api/terrain/heights?points=${encodeURIComponent(`${lon.toFixed(5)},${lat.toFixed(5)}`)}`;
|
||||
const url = new URL('/api/terrain/heights', APP_URL);
|
||||
url.searchParams.set('points', `${lon.toFixed(5)},${lat.toFixed(5)}`);
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
|
||||
if (!res.ok) return null;
|
||||
@@ -253,7 +261,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -279,7 +287,9 @@ async function main() {
|
||||
console.log('Loading app...');
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.dataManager,
|
||||
// The QA probe (`layers`) exists on the dev server only; a built
|
||||
// preview has the engine and the data manager without it.
|
||||
() => window.__godsEyeView?.engine && window.__godsEyeView.dataManager,
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
await sleep(4000);
|
||||
@@ -287,6 +297,13 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 1: CCTV ground correctness
|
||||
// =========================================================================
|
||||
const hasCctv = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
return gev.layers ? gev.layers.available('cctv') : gev.dataManager.layers.has('cctv');
|
||||
});
|
||||
let allCameras = [], londonCams = [], austinCams = [];
|
||||
if (!hasCctv) console.log(' [SKIP] CCTV ground/regime groups are outside the Navara W1 catalog');
|
||||
if (hasCctv) {
|
||||
console.log('Enabling CCTV layer (full city-packs catalog)...');
|
||||
await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
@@ -337,7 +354,7 @@ async function main() {
|
||||
// `nearbyPlace` string (NOT a stable "San Francisco" literal), so SF-area
|
||||
// Caltrans cameras are identified by proximity to the SF anchor instead
|
||||
// (same anchor vite.config.js's CALTRANS_ANCHORS uses for prioritization).
|
||||
const allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
allCameras = await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('cctv').module.getUIState().cameras);
|
||||
console.log(`Camera catalog: ${allCameras.length} total.`);
|
||||
|
||||
const SF_ANCHOR = { lat: 37.7793, lon: -122.4193 };
|
||||
@@ -349,8 +366,8 @@ async function main() {
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
const austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
londonCams = allCameras.filter((c) => c.city === 'London').slice(0, 5);
|
||||
austinCams = allCameras.filter((c) => c.city === 'Austin').slice(0, 5);
|
||||
const sfCams = allCameras
|
||||
.filter((c) => c.provider === 'Caltrans' && haversineKm(c.lat, c.lon, SF_ANCHOR.lat, SF_ANCHOR.lon) < 60)
|
||||
.slice(0, 3);
|
||||
@@ -372,13 +389,28 @@ async function main() {
|
||||
|
||||
for (const { label, cams } of cityBuckets) {
|
||||
for (const cam of cams) {
|
||||
// Coverage is built only in view; frame the camera before reading it.
|
||||
await page.evaluate((camera) => {
|
||||
const { viewer, engine } = window.__godsEyeView;
|
||||
engine.camera.cancelFlight();
|
||||
engine.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: camera.lon * Math.PI / 180,
|
||||
latitude: camera.lat * Math.PI / 180,
|
||||
height: 5000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
engine.render.request('qa-camera-datum');
|
||||
}, cam);
|
||||
await page.waitForFunction((id) => window.__godsEyeView.engine.lines.list().some(line => line.id === `cctv-${id}-ray-tl` && line.points > 0), { timeout: 20000 }, cam.id).catch(() => {});
|
||||
const state = await page.evaluate((id) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('cctv').module;
|
||||
return mod.getUIState().cameras.find((camera) => camera.id === id) || null;
|
||||
}, cam.id);
|
||||
const geom = await readCameraGround(page, cam.id);
|
||||
if (!geom || !Number.isFinite(geom.groundM)) {
|
||||
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl entity missing or non-finite height');
|
||||
record(`${label} ${cam.id}: record ground reads finitely`, false, 'ray-tl line missing or non-finite height');
|
||||
continue;
|
||||
}
|
||||
record(`${label} ${cam.id}: rendered ground reads finitely`, true,
|
||||
@@ -456,6 +488,8 @@ async function main() {
|
||||
record('Austin active camera google-3d ground ∈ [110,135]m', null, 'no Austin camera available to test');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Group 2: Aircraft render altitude
|
||||
// =========================================================================
|
||||
@@ -511,7 +545,7 @@ async function main() {
|
||||
// returns everything currently shown regardless of viewer position.
|
||||
const nearbyList = await page.evaluate(() => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get('flights').module;
|
||||
const center = window.__godsEyeView.viewer.camera.position;
|
||||
const center = window.__godsEyeView.engine.camera.livePosition;
|
||||
return mod.getNearby(center, Number.MAX_VALUE, 200).map((a) => ({ icao24: a.icao24, altitudeM: a.altitudeM }));
|
||||
});
|
||||
const baroById = new Map(nearbyList.map((a) => [a.icao24, a.altitudeM]));
|
||||
@@ -626,6 +660,7 @@ async function main() {
|
||||
// =========================================================================
|
||||
// Group 3: Regime C re-resolve
|
||||
// =========================================================================
|
||||
if (hasCctv) {
|
||||
console.log('Switching map stack to keyless OSM (regime C)...');
|
||||
// Grab a London camera's ground BEFORE the switch (still whatever regime
|
||||
// we were in — Google-3D, given the app's default).
|
||||
@@ -694,6 +729,8 @@ async function main() {
|
||||
record('CCTV grounds re-resolved for regime C', null, 'no test camera available');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshot for human visual review (not a pass/fail gate).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
// Recent imagery on a keyless preview, starting on Navara: the NASA catalog
|
||||
// and imagery tiles are fixtures, and the image, A/B, swap, divider, opacity,
|
||||
// basemap, toggle, failure, box-drag and zoom-to-fit checks read the rendered
|
||||
// pixels; both pinned dates survive a switch to Cesium and back. The report
|
||||
// and its screenshots go next to the evidence file (or to the temp directory).
|
||||
//
|
||||
// node scripts/qa-imagery-contract.mjs http://127.0.0.1:<PORT>
|
||||
// [--layers=recent-imagery] [--evidence=FILE]
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
strictArguments,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { isExpectedPreviewSetupError, redact } from './qa-browserEvidence.mjs';
|
||||
|
||||
const { origin } = localPreview(process.argv[2]);
|
||||
const url = origin + '/?engine=navara';
|
||||
const { evidence } = strictArguments();
|
||||
const output = evidence
|
||||
? path.join(path.dirname(evidence), 'imagery-report.json')
|
||||
: path.join(os.tmpdir(), 'gev-imagery.json');
|
||||
const report = {
|
||||
url,
|
||||
checks: [],
|
||||
errors: [],
|
||||
expectedErrors: [],
|
||||
requests: [],
|
||||
};
|
||||
let failLandsat = false,
|
||||
failedTiles = 0;
|
||||
// The engine the page runs, for the strict assertion manifest.
|
||||
let engine = null;
|
||||
const strict = createStrict({
|
||||
origin,
|
||||
layers: ['recent-imagery'],
|
||||
// The scenario answers the NASA catalog and imagery tiles itself.
|
||||
fixtures: ['*://*.earthdata.nasa.gov/*'],
|
||||
});
|
||||
const check = (name, value) => {
|
||||
report.checks.push({ name, pass: Boolean(value) });
|
||||
if (value && engine) strict.record('recent-imagery', engine, name);
|
||||
console.log(`${value ? 'PASS' : 'FAIL'} ${name}`);
|
||||
};
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const colors = await Promise.all(
|
||||
['#ed3838', '#35c969'].map((background) =>
|
||||
sharp({ create: { width: 256, height: 256, channels: 4, background } })
|
||||
.png()
|
||||
.toBuffer(),
|
||||
),
|
||||
);
|
||||
const browser = await strict.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
|
||||
page.on('pageerror', (e) => report.errors.push(redact(e.message)));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() !== 'error') return;
|
||||
const text = redact(message.text());
|
||||
const expected =
|
||||
isExpectedPreviewSetupError(message, {
|
||||
preview: true,
|
||||
baseUrl: url,
|
||||
}) ||
|
||||
(failedTiles > 0 &&
|
||||
((/\b503\b/.test(text) &&
|
||||
String(message.location()?.url).includes('HLS_L30')) ||
|
||||
(failLandsat && /Failed to obtain image tile/.test(text))));
|
||||
const source = message.location()?.url;
|
||||
(expected ? report.expectedErrors : report.errors).push(
|
||||
source ? text + ' (' + new URL(source, url).hostname + ')' : text,
|
||||
);
|
||||
});
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Fetch.enable', {
|
||||
patterns: [{ urlPattern: '*://*.earthdata.nasa.gov/*' }],
|
||||
});
|
||||
session.on('Fetch.requestPaused', async (event) => {
|
||||
try {
|
||||
const request = new URL(event.request.url);
|
||||
const catalog = request.hostname === 'cmr.earthdata.nasa.gov';
|
||||
const green = request.href.includes('2026-10-27');
|
||||
const tileFailure =
|
||||
failLandsat &&
|
||||
request.pathname.includes('HLS_L30') &&
|
||||
request.hostname.startsWith('gibs');
|
||||
if (tileFailure) failedTiles++;
|
||||
report.requests.push({ host: request.hostname, path: request.pathname });
|
||||
const body = catalog
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
hits: 2,
|
||||
items: ['2026-10-28', '2026-10-27'].map((day) => ({
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: `${day}T12:00:00Z` },
|
||||
},
|
||||
AdditionalAttributes: [
|
||||
{ Name: 'CLOUD_COVERAGE', Values: ['0'] },
|
||||
],
|
||||
SpatialExtent: {
|
||||
HorizontalSpatialDomain: {
|
||||
Geometry: {
|
||||
BoundingRectangles: [
|
||||
{
|
||||
WestBoundingCoordinate: -98,
|
||||
EastBoundingCoordinate: -97,
|
||||
SouthBoundingCoordinate: 30,
|
||||
NorthBoundingCoordinate: 31,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
)
|
||||
: colors[green ? 1 : 0];
|
||||
await session.send('Fetch.fulfillRequest', {
|
||||
requestId: event.requestId,
|
||||
responseCode: tileFailure ? 503 : 200,
|
||||
responseHeaders: [
|
||||
{
|
||||
name: 'Content-Type',
|
||||
value: catalog ? 'application/json' : 'image/png',
|
||||
},
|
||||
{ name: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ name: 'Access-Control-Expose-Headers', value: 'Data-Present' },
|
||||
{ name: 'Data-Present', value: 'true' },
|
||||
{ name: 'Cache-Control', value: 'no-store' },
|
||||
],
|
||||
body: body.toString('base64'),
|
||||
});
|
||||
} catch (e) {
|
||||
if (
|
||||
String(e.message).includes(
|
||||
'Fetch.fulfillRequest): Invalid InterceptionId',
|
||||
)
|
||||
)
|
||||
report.expectedErrors.push(
|
||||
'Fixture response cancelled before fulfillment',
|
||||
);
|
||||
else if (!page.isClosed()) report.errors.push(redact(e.message));
|
||||
}
|
||||
});
|
||||
const target = new URL(url);
|
||||
target.searchParams.set('welcome', '0');
|
||||
target.hash = 'lat=30.27&lon=-97.74&alt=12000&heading=0&pitch=-90&map=osm';
|
||||
await page.goto(target.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(
|
||||
() => window.__gevRecentImagery && window.__godsEyeView,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
engine = await page.evaluate(() => window.__godsEyeView.engine.kind);
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.styleManager.initialRestorePromise;
|
||||
app.styleManager.orbitController.stop();
|
||||
for (const [id, entry] of app.dataManager.layers)
|
||||
if (entry.enabled) await app.dataManager.setEnabled(id, false);
|
||||
app.styleManager._setDetectionMode('OFF');
|
||||
await app.dataManager.setEnabled('recent-imagery', true);
|
||||
window.__gevRecentImagery.layer.setBox({
|
||||
west: -97.77,
|
||||
east: -97.71,
|
||||
south: 30.25,
|
||||
north: 30.29,
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevRecentImagery.layer
|
||||
.getSnapshot()
|
||||
.candidates.filter(
|
||||
(c) => c.product === 'S30' && c.thumbnail?.status === 'present',
|
||||
).length >= 2,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
const c = l
|
||||
.getSnapshot()
|
||||
.candidates.filter((c) => c.product === 'S30')
|
||||
.sort((a, b) => b.day.localeCompare(a.day));
|
||||
l.setAssignment('a', c[0].key);
|
||||
l.setAssignment('b', c[1].key);
|
||||
l.setMode('image');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.surface.tilesLoaded(),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await sleep(4000);
|
||||
const capture = async (name) => {
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender('imagery-qa'));
|
||||
await sleep(350);
|
||||
const buffer = await page.screenshot();
|
||||
await fs.writeFile(output.replace(/\.json$/, `-${name}.png`), buffer);
|
||||
const pixels = await sharp(buffer)
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const sample = (x, y) =>
|
||||
Array.from(
|
||||
pixels.data.subarray(
|
||||
(y * pixels.info.width + x) * pixels.info.channels,
|
||||
(y * pixels.info.width + x) * pixels.info.channels + 3,
|
||||
),
|
||||
);
|
||||
const result = {
|
||||
left: sample(565, 400),
|
||||
right: sample(715, 400),
|
||||
outside: sample(340, 400),
|
||||
};
|
||||
report[name] = result;
|
||||
return result;
|
||||
};
|
||||
const red = (p) => p[0] > p[1] * 1.6 && p[0] > 100;
|
||||
const green = (p) => p[1] > p[0] * 1.6 && p[1] > 100;
|
||||
let image = await capture('image');
|
||||
check(
|
||||
'image pixels inside selected box',
|
||||
red(image.left) && red(image.right),
|
||||
);
|
||||
check('geographic clip outside box', !red(image.outside));
|
||||
await page.evaluate(() => window.__gevRecentImagery.layer.setMode('ab'));
|
||||
await sleep(2500);
|
||||
image = await capture('ab');
|
||||
check('A/B dates on opposite sides', red(image.left) && green(image.right));
|
||||
const first = await page.evaluate(() => {
|
||||
window.qaImageryOwner = window.__gevRecentImagery.layer;
|
||||
return window.qaImageryOwner.diagnostics().pins;
|
||||
});
|
||||
const reads = () =>
|
||||
report.requests.filter((r) => r.host === 'cmr.earthdata.nasa.gov').length;
|
||||
const expectedReads = reads();
|
||||
const initial = await page.evaluate(() => window.__godsEyeView.engine.kind);
|
||||
for (const kind of [initial === 'navara' ? 'cesium' : 'navara', initial]) {
|
||||
await strict.switchTo(page, kind);
|
||||
engine = kind;
|
||||
const state = await page.evaluate(() => ({
|
||||
same: window.__gevRecentImagery.layer === window.qaImageryOwner,
|
||||
...window.qaImageryOwner.diagnostics(),
|
||||
}));
|
||||
check(
|
||||
kind + ' holds both original pins',
|
||||
state.same &&
|
||||
state.mode === 'ab' &&
|
||||
state.pins.a === first.a &&
|
||||
state.pins.b === first.b,
|
||||
);
|
||||
await sleep(1800);
|
||||
const pixels = await capture('switch-' + kind);
|
||||
check(
|
||||
kind + ' renders both pinned dates',
|
||||
red(pixels.left) && green(pixels.right),
|
||||
);
|
||||
check(kind + ' reuses the STAC search', reads() === expectedReads);
|
||||
}
|
||||
await page.evaluate(() => window.__gevRecentImagery.layer.swapSides());
|
||||
image = await capture('swap');
|
||||
check('swap reverses dates', green(image.left) && red(image.right));
|
||||
await page.evaluate(() => window.__gevRecentImagery.layer.swapSides());
|
||||
await page.mouse.move(640, 400);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(780, 400, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
image = await capture('divider');
|
||||
check(
|
||||
'physical divider drag changes rendered split',
|
||||
red(image.left) && red(image.right),
|
||||
);
|
||||
await page.focus('.recent-imagery-split-handle');
|
||||
await page.keyboard.press('Home');
|
||||
image = await capture('home');
|
||||
check('divider Home exposes B', green(image.left) && green(image.right));
|
||||
await page.keyboard.press('End');
|
||||
image = await capture('end');
|
||||
check('divider End exposes A', red(image.left) && red(image.right));
|
||||
await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
l.setMode('image');
|
||||
l.setAlpha(0);
|
||||
});
|
||||
const base = await capture('transparent');
|
||||
await page.evaluate(() => window.__gevRecentImagery.layer.setAlpha(0.5));
|
||||
const half = await capture('half');
|
||||
check(
|
||||
'half opacity blends source with basemap',
|
||||
half.left.every(
|
||||
(c, i) => Math.abs(c - (base.left[i] + report.image.left[i]) / 2) < 5,
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
l.setAlpha(1);
|
||||
l.setMode('basemap');
|
||||
});
|
||||
image = await capture('basemap');
|
||||
check(
|
||||
'basemap comparison clips only A',
|
||||
red(image.left) && !red(image.right),
|
||||
);
|
||||
report.beforeToggle = await page.evaluate(() =>
|
||||
window.__gevRecentImagery.layer.diagnostics(),
|
||||
);
|
||||
for (let i = 0; i < 10; i++)
|
||||
await page.evaluate(async () => {
|
||||
const app = window.__godsEyeView;
|
||||
await app.dataManager.setEnabled('recent-imagery', false);
|
||||
await app.dataManager.setEnabled('recent-imagery', true);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__gevRecentImagery.layer.diagnostics().ownedCount === 1,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
await sleep(2000);
|
||||
image = await capture('toggles');
|
||||
check('ten toggles restore visible image', red(image.left));
|
||||
report.afterToggle = await page.evaluate(() =>
|
||||
window.__gevRecentImagery.layer.diagnostics(),
|
||||
);
|
||||
if (report.afterToggle.renderer)
|
||||
check(
|
||||
'Navara tile admission stays bounded',
|
||||
report.afterToggle.renderer.peak <= 6 &&
|
||||
Object.values(report.afterToggle.renderer.slots).every(
|
||||
(s) => !s || s.tiles <= 64,
|
||||
),
|
||||
);
|
||||
failLandsat = true;
|
||||
await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
l.setMode('image');
|
||||
l.setAssignment(
|
||||
'a',
|
||||
l
|
||||
.getSnapshot()
|
||||
.candidates.find((c) => c.product === 'L30' && c.day === '2026-10-28')
|
||||
.key,
|
||||
);
|
||||
});
|
||||
await sleep(2000);
|
||||
image = await capture('source-failure');
|
||||
check(
|
||||
'failed replacement does not leave stale image pixels',
|
||||
failedTiles > 0 && !red(image.left),
|
||||
);
|
||||
report.failedTiles = failedTiles;
|
||||
failLandsat = false;
|
||||
await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
const keys = l
|
||||
.getSnapshot()
|
||||
.candidates.filter((c) => c.product === 'S30')
|
||||
.sort((a, b) => b.day.localeCompare(a.day))
|
||||
.map((c) => c.key);
|
||||
for (let i = 0; i < 10; i++) l.setAssignment('a', keys[i % 2]);
|
||||
});
|
||||
await sleep(2000);
|
||||
image = await capture('replaced');
|
||||
check(
|
||||
'rapid replacement leaves final date only',
|
||||
green(image.left) && green(image.right),
|
||||
);
|
||||
await page.evaluate(() => window.__gevRecentImagery.layer.clear());
|
||||
await sleep(1000);
|
||||
check(
|
||||
'clear removes both slots',
|
||||
await page.evaluate(
|
||||
() => window.__gevRecentImagery.layer.diagnostics().ownedCount === 0,
|
||||
),
|
||||
);
|
||||
const dragStart = await page.evaluate(() => {
|
||||
const app = window.__godsEyeView;
|
||||
return {
|
||||
pose: app.engine.camera.pose().position,
|
||||
started: window.__gevRecentImagery.tool.start(),
|
||||
};
|
||||
});
|
||||
await page.mouse.move(480, 300);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(780, 500, { steps: 12 });
|
||||
await capture('box-drag');
|
||||
await page.mouse.up();
|
||||
const dragged = await page.evaluate(() => ({
|
||||
box: window.__gevRecentImagery.layer.getSnapshot().box,
|
||||
active: window.__gevRecentImagery.tool.isActive(),
|
||||
pose: window.__godsEyeView.engine.camera.pose().position,
|
||||
}));
|
||||
check(
|
||||
'physical box drag selects geography without moving camera',
|
||||
dragStart.started &&
|
||||
dragged.box &&
|
||||
!dragged.active &&
|
||||
['x', 'y', 'z'].every(
|
||||
(k) => Math.abs(dragged.pose[k] - dragStart.pose[k]) < 0.1,
|
||||
),
|
||||
);
|
||||
const usedView = await page.evaluate(() =>
|
||||
window.__gevRecentImagery.layer.useCurrentView(),
|
||||
);
|
||||
check('USE VIEW works on real engine camera', usedView);
|
||||
await page.evaluate(() => {
|
||||
const e = window.__godsEyeView.engine,
|
||||
p = e.camera.pose();
|
||||
e.camera.setView({
|
||||
destination: {
|
||||
x: p.position.x * 1.2,
|
||||
y: p.position.y * 1.2,
|
||||
z: p.position.z * 1.2,
|
||||
},
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.engine.camera.pose().cartographic.height > 1000000,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await sleep(350);
|
||||
const fit = (report.fit = await page.evaluate(() => {
|
||||
const l = window.__gevRecentImagery.layer;
|
||||
const used = l.useCurrentView();
|
||||
return { used, offered: l.getSnapshot().zoomToFit, height: l.zoomToFit() };
|
||||
}));
|
||||
check(
|
||||
'oversized view refuses and offers bounded zoom',
|
||||
!fit.used && fit.offered && fit.height >= 5000 && fit.height <= 400000,
|
||||
);
|
||||
await sleep(2200);
|
||||
check(
|
||||
'ZOOM IN lands at requested height',
|
||||
await page.evaluate(
|
||||
(h) =>
|
||||
Math.abs(
|
||||
window.__godsEyeView.engine.camera.pose().cartographic.height - h,
|
||||
) < 5,
|
||||
fit.height,
|
||||
),
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
window.__gevRecentImagery.layer.clear();
|
||||
await window.__godsEyeView.dataManager.setEnabled('recent-imagery', false);
|
||||
});
|
||||
await sleep(1000);
|
||||
report.disabled = await page.evaluate(() =>
|
||||
window.__gevRecentImagery.layer.diagnostics(),
|
||||
);
|
||||
check(
|
||||
'disable leaves no imagery owners or queued requests',
|
||||
report.disabled.ownedCount === 0 &&
|
||||
(!report.disabled.renderer ||
|
||||
(report.disabled.renderer.active === 0 &&
|
||||
report.disabled.renderer.queued === 0)),
|
||||
);
|
||||
check('no browser exceptions', report.errors.length === 0);
|
||||
} catch (e) {
|
||||
report.errors.push(e.stack);
|
||||
check('driver completes', false);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fs.writeFile(output, JSON.stringify(report, null, 2));
|
||||
}
|
||||
const failed = report.checks.some((c) => !c.pass);
|
||||
await strict.finish({ failed: failed ? 'imagery checks failed' : null });
|
||||
if (failed) process.exitCode = 1;
|
||||
+26
-12
@@ -83,6 +83,20 @@ try {
|
||||
await page.goto(testUrl.href, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.styleManager
|
||||
&& document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 90_000 });
|
||||
// The startup view restore and any boot flight land after the loading
|
||||
// screen; park only once they are over, or they move the camera away from
|
||||
// the full-earth view before the first LOD walk.
|
||||
await page.evaluate(async () => {
|
||||
const gev = window.__godsEyeView;
|
||||
await gev.styleManager.initialRestorePromise;
|
||||
let quietSince = performance.now();
|
||||
const deadline = performance.now() + 30_000;
|
||||
while (performance.now() < deadline) {
|
||||
if (gev.engine.camera.flightActive()) quietSince = performance.now();
|
||||
else if (performance.now() - quietSince > 3_000) break;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
});
|
||||
check('first-run chooser does not cover the test surface', await page.evaluate(() => (
|
||||
!document.querySelector('#first-run-launcher:not([hidden])')
|
||||
)));
|
||||
@@ -158,8 +172,12 @@ try {
|
||||
for (const id of infraIds) {
|
||||
out[id] = gev.dataManager.layers.get(id)?.module?.getLodDiagnostics?.() || null;
|
||||
}
|
||||
return out;
|
||||
}, INFRA_LAYER_IDS).then((lod) => { report(label, lod); return lod; });
|
||||
// Where the camera is when the LOD is read (reported, not compared).
|
||||
return { lod: out, cameraHeightM: Math.round(gev.engine.camera.height()) };
|
||||
}, INFRA_LAYER_IDS).then(({ lod, cameraHeightM }) => {
|
||||
report(label, { ...lod, cameraHeightM });
|
||||
return lod;
|
||||
});
|
||||
|
||||
if (!CONTROL) {
|
||||
const globalLod = await readLod('getLodDiagnostics @ full-earth');
|
||||
@@ -216,15 +234,10 @@ try {
|
||||
// ── GATE c: no churn between camera moves ───────────────────────────
|
||||
const churn = await page.evaluate(async (infraIds) => {
|
||||
const gev = window.__godsEyeView;
|
||||
const snap = () => infraIds.map((id) => {
|
||||
const visibleIds = [];
|
||||
for (let i = 0; i < gev.viewer.dataSources.length; i++) {
|
||||
for (const entity of gev.viewer.dataSources.get(i).entities.values) {
|
||||
if (entity.__localLayerId === id && entity.show) visibleIds.push(String(entity.id));
|
||||
}
|
||||
}
|
||||
return visibleIds.sort();
|
||||
});
|
||||
// A batch lists the visible references' pick ids in drawing order.
|
||||
const snap = () => infraIds.map((id) => (gev.engine.lines.list()
|
||||
.find((line) => line.id === `${id}-stems` && line.shown)?.pickIds ?? [])
|
||||
.slice().sort());
|
||||
const before = snap();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
gev.viewer.scene.requestRender?.();
|
||||
@@ -297,7 +310,8 @@ try {
|
||||
// ── parked-idle honesty: the LOD walk must not force continuous render ─
|
||||
await new Promise((r) => setTimeout(r, 4_000));
|
||||
const idle = await page.evaluate(async () => {
|
||||
const { getRenderGovernorDiagnostics } = await import('/src/renderGovernor.js');
|
||||
// The debug handle's own governor read (no /src import: runs on a build).
|
||||
const { getRenderGovernorDiagnostics } = window.__godsEyeView;
|
||||
return new Promise((resolve) => {
|
||||
const scene = window.__godsEyeView.viewer.scene;
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
// Every geometry of the bundled files (a Multi* feature counts each part),
|
||||
// read from the files this checkout builds.
|
||||
const geometries = (name) =>
|
||||
readFileSync(
|
||||
new URL(`../src/data/local_data/${name}/${name}.geojsonl`, import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.reduce((sum, line) => {
|
||||
const { type, coordinates } = JSON.parse(line).geometry;
|
||||
return sum + (type.startsWith('Multi') ? coordinates.length : 1);
|
||||
}, 0);
|
||||
const DATACENTERS = geometries('datacenters');
|
||||
const DAMS = geometries('dams');
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['local-dams', 'local-datacenters'],
|
||||
});
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
const reads = { datacenters: 0, dams: 0 };
|
||||
p.on('request', (req) => {
|
||||
const path = new URL(req.url()).pathname;
|
||||
for (const name of Object.keys(reads))
|
||||
if (path.includes(name) && path.endsWith('.geojsonl')) reads[name]++;
|
||||
});
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
window.qaSelections = [];
|
||||
window.addEventListener('gev:entity-selected', ({ detail }) => {
|
||||
if (detail?.layerId?.startsWith('local-'))
|
||||
window.qaSelections.push(detail.id);
|
||||
});
|
||||
window.qaCleared = 0;
|
||||
window.addEventListener('gev:entity-selection-cleared', () => {
|
||||
window.qaCleared++;
|
||||
});
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
const ids = ['local-datacenters', 'local-dams'];
|
||||
const dataset = await p.evaluate(async (ids) => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
window.qaSites = [];
|
||||
const records = [];
|
||||
for (const id of ids) {
|
||||
if (!(await dataManager.setEnabled(id, true, { origin: 'user' })))
|
||||
throw Error(id + ' did not enable');
|
||||
const mod = dataManager.layers.get(id).module;
|
||||
window.qaSites.push(mod);
|
||||
const all = mod.getAnalystRecords(Infinity);
|
||||
records.push({
|
||||
count: mod.getStats().count,
|
||||
total: all.length,
|
||||
name: all[0]?.name,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}, ids);
|
||||
// The bundled files are fixed: every record is loaded.
|
||||
assert.deepEqual(
|
||||
dataset.map((d) => d.count),
|
||||
[DATACENTERS, DAMS],
|
||||
JSON.stringify(dataset),
|
||||
);
|
||||
const record = await p.evaluate(() =>
|
||||
window.qaSites[0]
|
||||
.getAnalystRecords(5000)
|
||||
.find((r) => r.name === 'Geneseo Communications Inc'),
|
||||
);
|
||||
assert.ok(record);
|
||||
const clickSite = async () => {
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
return { x: box.left + at.x, y: box.top + at.y };
|
||||
},
|
||||
fromDegrees(record.lon, record.lat),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.at(-1) === 'local-datacenters:342664739',
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
};
|
||||
// A dam in view, clicked where its record is.
|
||||
const dam = await p.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('local-dams')
|
||||
.module.getAnalystRecords(5000)
|
||||
.find((r) => r.name === 'Arsenal Power Dam'),
|
||||
);
|
||||
assert.ok(dam);
|
||||
const clickDam = async () => {
|
||||
const screen = await p.evaluate(
|
||||
(world) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const at = engine.projector.worldToWindow(world, {});
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
window.qaSelections = [];
|
||||
return { x: box.left + at.x, y: box.top + at.y };
|
||||
},
|
||||
fromDegrees(dam.lon, dam.lat),
|
||||
);
|
||||
await p.mouse.click(screen.x, screen.y);
|
||||
await p
|
||||
.waitForFunction(
|
||||
() => window.qaSelections.at(-1) === 'local-dams:-12841971',
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'dam click',
|
||||
JSON.stringify(screen),
|
||||
JSON.stringify(
|
||||
await p.evaluate(({ x, y }) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const box = engine.host.canvas.getBoundingClientRect();
|
||||
const id = engine.picker.pick({
|
||||
x: x - box.left,
|
||||
y: y - box.top,
|
||||
})?.id;
|
||||
return {
|
||||
selections: window.qaSelections,
|
||||
cleared: window.qaCleared,
|
||||
pick: String(id?.id ?? id),
|
||||
hit: document.elementFromPoint(x, y)?.tagName,
|
||||
};
|
||||
}, screen),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
const start = fromDegrees(-90.507, 41.534, 20000);
|
||||
const home = async () => {
|
||||
await p.evaluate((destination) => {
|
||||
const { camera, render } = window.__godsEyeView.engine;
|
||||
camera.cancelFlight();
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
render.request('qa-home');
|
||||
}, start);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
};
|
||||
const counts = { ...reads };
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if ((await p.evaluate(() => window.__godsEyeView.engine.kind)) !== kind) {
|
||||
await p.evaluate(() => {
|
||||
window.qaSelections = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(
|
||||
() => window.qaSelections.includes('local-datacenters:342664739'),
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const result = await p.evaluate(
|
||||
(ids) =>
|
||||
ids.map((id, i) => {
|
||||
const mod = window.__godsEyeView.dataManager.layers.get(id).module;
|
||||
return {
|
||||
same: mod === window.qaSites[i],
|
||||
lod: mod.getLodDiagnostics(),
|
||||
count: mod.getStats().count,
|
||||
error: mod.getStats().error,
|
||||
name: mod.getAnalystRecords(1)[0].name,
|
||||
};
|
||||
}),
|
||||
ids,
|
||||
);
|
||||
console.log(kind, JSON.stringify(result));
|
||||
for (const [i, layer] of result.entries()) {
|
||||
assert.ok(
|
||||
layer.same &&
|
||||
!layer.error &&
|
||||
layer.lod.active > 0 &&
|
||||
layer.lod.active <= layer.lod.budgetLimit,
|
||||
);
|
||||
assert.equal(layer.count, dataset[i].count);
|
||||
assert.equal(layer.name, dataset[i].name);
|
||||
}
|
||||
assert.deepEqual(
|
||||
reads,
|
||||
counts,
|
||||
'Do not reload bundled data on scene handoff',
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
ids,
|
||||
'same model, every bundled record, bounded LOD stems, no file read again',
|
||||
result.map((r) => r.count),
|
||||
);
|
||||
if (
|
||||
kind !== 'navara' ||
|
||||
(await p.evaluate(() => window.qaSelections.length))
|
||||
)
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-datacenters',
|
||||
'the clicked datacenter stays selected across the switch',
|
||||
);
|
||||
// Click-picking on this engine: a dam, then the datacenter again, each
|
||||
// from the starting view (a selection flies the camera to it).
|
||||
await home();
|
||||
await clickDam();
|
||||
await strict.passed(p, 'local-dams', 'a click on a dam selects it');
|
||||
await home();
|
||||
await clickSite();
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-datacenters',
|
||||
'a click on the datacenter selects it',
|
||||
);
|
||||
}
|
||||
assert.equal(counts.datacenters, 1);
|
||||
assert.equal(counts.dams, 1);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS complete bundled datasets, bounded stems, retained selection and one read per file',
|
||||
);
|
||||
});
|
||||
@@ -40,9 +40,20 @@ try {
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') result.errors.push(m.text());
|
||||
});
|
||||
await page.evaluateOnNewDocument(() =>
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
||||
);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed');
|
||||
// The sites drawn as discs, as their context handles: the layer draws
|
||||
// through the globe contract and owns no Cesium entities.
|
||||
window.__qaInstallationSites = () =>
|
||||
[...(window.__gevContextStore?.entities.values() ?? [])]
|
||||
.filter(
|
||||
(r) =>
|
||||
r.layerId === 'military-installations' &&
|
||||
r.entity &&
|
||||
!r.entity.record?.namedArea,
|
||||
)
|
||||
.map((r) => r.entity);
|
||||
});
|
||||
await page.goto(
|
||||
args.find((a) => /^https?:/.test(a)),
|
||||
{ waitUntil: 'domcontentloaded' },
|
||||
@@ -156,8 +167,9 @@ try {
|
||||
.catch(() => {});
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
const ds = g.viewer.dataSources.getByName('military-installations')[0];
|
||||
const points = ds.entities.values.filter((e) => e.point);
|
||||
// The drawn sites' context handles (render/display.js keeps one per
|
||||
// drawn record and prunes the rest); a named area draws no disc.
|
||||
const points = window.__qaInstallationSites();
|
||||
const stable = points.every(
|
||||
(e) =>
|
||||
!window.installationEntityRefs.has(e.id) ||
|
||||
@@ -244,10 +256,7 @@ try {
|
||||
0.6,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__godsEyeView.viewer.dataSources
|
||||
.getByName('military-installations')[0]
|
||||
.entities.values.some((e) => e.point),
|
||||
() => window.__qaInstallationSites().length > 0,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
@@ -268,8 +277,7 @@ try {
|
||||
await sleep(1000);
|
||||
await page.evaluate(async () => {
|
||||
const g = window.__godsEyeView;
|
||||
const ds = g.viewer.dataSources.getByName('military-installations')[0];
|
||||
const id = ds.entities.values.find((e) => e.point).id;
|
||||
const id = window.__qaInstallationSites()[0].id;
|
||||
window.installationSubjectId = id;
|
||||
await g.dataManager.layers
|
||||
.get('military-awareness')
|
||||
|
||||
@@ -10,25 +10,29 @@ export async function checkInstallationPan(page, dir) {
|
||||
);
|
||||
await sleep(3000);
|
||||
await page.evaluate(async () => {
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const g = window.__godsEyeView;
|
||||
const installations = g.dataManager.layers.get(
|
||||
'military-installations',
|
||||
).module;
|
||||
// The datacenters' overlay entry ids: the layer draws through the globe
|
||||
// contract and registers each reference row (its overlay entry's id) in
|
||||
// the context store; it owns no Cesium entities.
|
||||
const centerIds = [];
|
||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (e.__localLayerId === 'local-datacenters') centerIds.push(e.id);
|
||||
for (const record of window.__gevContextStore?.entities.values() ?? [])
|
||||
if (record.layerId === 'local-datacenters' && record.entity?.id != null)
|
||||
centerIds.push(record.entity.id);
|
||||
window.__polishPanFrames = [];
|
||||
window.__stopPolishPan = g.viewer.scene.postRender.addEventListener(() => {
|
||||
const sites = [],
|
||||
centers = [];
|
||||
installations.visitNamedMarkers((id) => {
|
||||
if (getOverlayPaintRect('military-installations', id)) sites.push(id);
|
||||
if (getPaintRect('military-installations', id)) sites.push(id);
|
||||
});
|
||||
for (const id of centerIds)
|
||||
if (getOverlayPaintRect('local-datacenters', id)) centers.push(id);
|
||||
if (getPaintRect('local-datacenters', id)) centers.push(id);
|
||||
window.__polishPanFrames.push({ t: performance.now(), sites, centers });
|
||||
});
|
||||
});
|
||||
@@ -106,6 +110,17 @@ export async function checkInstallationSelection(
|
||||
dir,
|
||||
{ video = true } = {},
|
||||
) {
|
||||
await page.evaluate(() => {
|
||||
const ground = window.__godsEyeView.engine.ground;
|
||||
const create = ground.createSet.bind(ground);
|
||||
window.__polishGroundSets = new Map();
|
||||
ground.createSet = (options) => {
|
||||
const set = create(options);
|
||||
window.__polishGroundSets.set(options.id, { set, drape: options.drape ?? 'both' });
|
||||
return set;
|
||||
};
|
||||
window.__restorePolishGround = () => { ground.createSet = create; };
|
||||
});
|
||||
const result = [];
|
||||
const recorder = video
|
||||
? await createScreencast(page, `${dir}/selection-orbit`, { fps: 24 })
|
||||
@@ -173,67 +188,52 @@ export async function checkInstallationSelection(
|
||||
window.__polishSelectionFrames = [];
|
||||
const { getSelectedEntityContext } =
|
||||
await import('/src/data/contextStore.js');
|
||||
const { getOverlayPaintRect, getWorldOverlayDiagnostics } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const knownFills = new Set();
|
||||
const fillEntities = () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < g.viewer.dataSources.length; i++)
|
||||
for (const e of g.viewer.dataSources.get(i).entities.values)
|
||||
if (e.id.startsWith(`${record.id}:selected-fill:`)) list.push(e);
|
||||
for (const e of list) knownFills.add(e);
|
||||
return list;
|
||||
};
|
||||
// Geometry-instance ids prove the ground primitive actually built, not just an entity declaration.
|
||||
const primitives = () => {
|
||||
let count = 0;
|
||||
const walk = (collection) => {
|
||||
if (collection?._collectionsArray) {
|
||||
for (const child of collection._collectionsArray) walk(child);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < (collection?.length || 0); i++) {
|
||||
const p = collection.get(i);
|
||||
if (
|
||||
p.ready &&
|
||||
typeof p.getGeometryInstanceAttributes === 'function'
|
||||
) {
|
||||
for (const e of knownFills) {
|
||||
try {
|
||||
const a = p.getGeometryInstanceAttributes(e);
|
||||
if (a && (!a.show || a.show[0])) count++;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (typeof p.get === 'function' || p._collectionsArray) walk(p);
|
||||
const { getPaintRect, getDiagnostics } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
// The selected site's fill is one engine.ground polygon set
|
||||
// (render/display.js SELECTION_FILL_SET_ID), an item per footprint
|
||||
// part; the layer owns no Cesium entities or ground primitives.
|
||||
const fillSet = () =>
|
||||
g.engine.ground
|
||||
.list()
|
||||
.find((set) => set.id === 'military-installations-selection-fill');
|
||||
window.__polishFillProbe = () => {
|
||||
const set = fillSet();
|
||||
const items = set?.shown ? set.count : 0;
|
||||
const captured = window.__polishGroundSets.get(set?.id);
|
||||
// A retained fill may predate this probe. Its engine-owned batches
|
||||
// prove readiness and classification on this Cesium-only check.
|
||||
const batches = [];
|
||||
const visit = collection => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const primitive = collection.get(i);
|
||||
if (primitive.gevGroundSet === set?.id) batches.push(primitive);
|
||||
else if (typeof primitive.get === 'function') visit(primitive);
|
||||
}
|
||||
};
|
||||
walk(g.viewer.scene.groundPrimitives);
|
||||
return count;
|
||||
if (!captured && set) visit(g.viewer.scene.groundPrimitives);
|
||||
const ready = captured ? captured.set.ready() : batches.length > 0 && batches.every(p => p.ready);
|
||||
const drape = captured?.drape ?? ['terrain', 'tiles', 'both'][batches[0]?.classificationType];
|
||||
return {
|
||||
items,
|
||||
readyItems: ready ? items : 0,
|
||||
drape,
|
||||
expectedDrape: g.engine.maps.photorealActive() ? 'tiles' : 'both',
|
||||
selected: getSelectedEntityContext()?.id || null,
|
||||
};
|
||||
};
|
||||
window.__polishFillProbe = () => ({
|
||||
entities: fillEntities().length,
|
||||
primitives: primitives(),
|
||||
classification: fillEntities().map((e) =>
|
||||
e.polygon.classificationType.getValue(),
|
||||
),
|
||||
selected: getSelectedEntityContext()?.id || null,
|
||||
});
|
||||
window.__stopPolishSelection =
|
||||
g.viewer.scene.postRender.addEventListener(() => {
|
||||
window.__polishSelectionFrames.push({
|
||||
paints: window.__polishTitlePaints.splice(0).length,
|
||||
own: Boolean(
|
||||
getOverlayPaintRect('military-installations', record.id),
|
||||
),
|
||||
own: Boolean(getPaintRect('military-installations', record.id)),
|
||||
tracked: Boolean(
|
||||
getOverlayPaintRect('tracked', `installations:${record.id}`),
|
||||
getPaintRect('tracked', `installations:${record.id}`),
|
||||
),
|
||||
selected: getSelectedEntityContext()?.id === record.id,
|
||||
total:
|
||||
getWorldOverlayDiagnostics().paintedBySource[
|
||||
'military-installations'
|
||||
] || 0,
|
||||
getDiagnostics().paintedBySource['military-installations'] || 0,
|
||||
});
|
||||
});
|
||||
return {
|
||||
@@ -251,7 +251,7 @@ export async function checkInstallationSelection(
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const p = window.__polishFillProbe();
|
||||
return p.selected === window.__polishSelectedId && p.primitives > 0;
|
||||
return p.selected === window.__polishSelectedId && p.readyItems > 0;
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
@@ -259,27 +259,12 @@ export async function checkInstallationSelection(
|
||||
await page.screenshot({ path: `${dir}/selection-failure.png` });
|
||||
const diagnostic = await page.evaluate(() => {
|
||||
const g = window.__godsEyeView;
|
||||
const describe = (p) => ({
|
||||
type: p.constructor.name,
|
||||
ready: p.ready,
|
||||
length: p.length,
|
||||
keys: Object.keys(p).filter((k) =>
|
||||
/primitive|instance|collection/i.test(k),
|
||||
),
|
||||
ids: p._instanceIds?.map((id) => id.id || String(id)),
|
||||
children:
|
||||
typeof p.get === 'function'
|
||||
? Array.from({ length: p.length }, (_, i) =>
|
||||
describe(p.get(i)),
|
||||
)
|
||||
: p._collectionsArray?.map(describe),
|
||||
});
|
||||
return {
|
||||
probe: window.__polishFillProbe(),
|
||||
stats: g.dataManager.layers
|
||||
.get('military-installations')
|
||||
.module.getStats(),
|
||||
primitives: describe(g.viewer.scene.groundPrimitives),
|
||||
ground: g.engine.ground.list(),
|
||||
frames: window.__polishSelectionFrames.slice(-5),
|
||||
};
|
||||
});
|
||||
@@ -332,9 +317,9 @@ export async function checkInstallationSelection(
|
||||
const g = window.__godsEyeView;
|
||||
for (let y = 250; y < 650; y += 75)
|
||||
for (let x = 400; x < 850; x += 75) {
|
||||
const pick = g.viewer.scene.pick({ x, y });
|
||||
if (!pick?.id?.installationId && !pick?.id?.gevTrackedId)
|
||||
return { x, y };
|
||||
// Nothing drawn there: no mover, line or ground item's pickId.
|
||||
const pick = g.engine.picker.pick({ x, y });
|
||||
if (pick?.id == null) return { x, y };
|
||||
}
|
||||
throw new Error('No empty map point');
|
||||
});
|
||||
@@ -342,7 +327,7 @@ export async function checkInstallationSelection(
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__polishFillProbe().selected === null &&
|
||||
window.__polishFillProbe().entities === 0,
|
||||
window.__polishFillProbe().items === 0,
|
||||
);
|
||||
await sleep(600);
|
||||
const deselected = await page.evaluate(() => {
|
||||
@@ -353,14 +338,11 @@ export async function checkInstallationSelection(
|
||||
frames: window.__polishSelectionFrames,
|
||||
};
|
||||
});
|
||||
assert.equal(selected.entities, picked.parts);
|
||||
assert.equal(selected.primitives, picked.parts);
|
||||
assert.ok(
|
||||
selected.classification.every((c) => c === 1),
|
||||
'Google 3D tile classification',
|
||||
);
|
||||
assert.equal(deselected.entities, 0);
|
||||
assert.equal(deselected.primitives, 0);
|
||||
assert.equal(selected.items, picked.parts);
|
||||
assert.equal(selected.readyItems, picked.parts);
|
||||
assert.equal(selected.drape, selected.expectedDrape, 'selection fill follows the map surface');
|
||||
assert.equal(deselected.items, 0);
|
||||
assert.equal(deselected.readyItems, 0);
|
||||
const selectedFrames = deselected.frames.filter(
|
||||
(f) => f.selected && f.own,
|
||||
);
|
||||
@@ -381,6 +363,7 @@ export async function checkInstallationSelection(
|
||||
result.push({ site, picked, selected, deselected });
|
||||
}
|
||||
} finally {
|
||||
await page.evaluate(() => window.__restorePolishGround?.());
|
||||
await fs.writeFile(
|
||||
`${dir}/selection-checks.json`,
|
||||
JSON.stringify(result, null, 2),
|
||||
@@ -396,8 +379,8 @@ export async function checkInstallationSelection(
|
||||
id: picked.id,
|
||||
selected,
|
||||
deselected: {
|
||||
entities: deselected.entities,
|
||||
primitives: deselected.primitives,
|
||||
entities: deselected.items,
|
||||
primitives: deselected.readyItems,
|
||||
},
|
||||
frames: deselected.frames.length,
|
||||
}));
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
* `<dir>/clip.mp4` through ffmpeg's concat demuxer with per-frame
|
||||
* durations, so held frames last as long as they did on screen.
|
||||
* - `installFrameProbe(page)` measures every animation frame in the page:
|
||||
* the requestAnimationFrame interval and, for frames Cesium rendered, the
|
||||
* CPU time between `scene.preUpdate` and `scene.postRender`.
|
||||
* the requestAnimationFrame interval and, for rendered frames, the
|
||||
* CPU time between engine preUpdate and postRender events.
|
||||
* - `frameStats(frames)` reduces a probe window to count / mean / p50 / p95.
|
||||
* - `moveCamera(page, view, seconds)` eases the camera like a drag or wheel
|
||||
* zoom rather than teleporting it.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Percentile of a numeric list (nearest rank); NaN for an empty list. */
|
||||
export function percentile(values, p) {
|
||||
@@ -25,7 +28,7 @@ export function percentile(values, p) {
|
||||
];
|
||||
}
|
||||
|
||||
/** Summarize probe frames: rAF interval and Cesium render CPU time (ms). */
|
||||
/** Summarize probe frames: rAF interval and engine render CPU time (ms). */
|
||||
export function frameStats(frames) {
|
||||
const intervals = frames.map((f) => f.dt).filter(Number.isFinite);
|
||||
const renders = frames.map((f) => f.renderMs).filter(Number.isFinite);
|
||||
@@ -49,16 +52,17 @@ export function frameStats(frames) {
|
||||
export async function installFrameProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.__gevFrameProbe) return;
|
||||
const { viewer } = window.__godsEyeView;
|
||||
const { viewer, engine } = window.__godsEyeView;
|
||||
const events = viewer?.scene ?? engine.frames;
|
||||
const probe = { frames: [], started: 0 };
|
||||
window.__gevFrameProbe = probe;
|
||||
let last = performance.now();
|
||||
let renderStart = null;
|
||||
let pendingRender = null;
|
||||
viewer.scene.preUpdate.addEventListener(() => {
|
||||
events.preUpdate.addEventListener(() => {
|
||||
renderStart = performance.now();
|
||||
});
|
||||
viewer.scene.postRender.addEventListener(() => {
|
||||
events.postRender.addEventListener(() => {
|
||||
if (renderStart !== null) pendingRender = performance.now() - renderStart;
|
||||
renderStart = null;
|
||||
});
|
||||
@@ -98,6 +102,34 @@ export function pageNow(page) {
|
||||
* @param {number} seconds Flight duration; 0 jumps.
|
||||
*/
|
||||
export async function moveCamera(page, view, seconds) {
|
||||
if (
|
||||
await page.evaluate(() => window.__godsEyeView.engine?.kind === 'navara')
|
||||
) {
|
||||
await page.evaluate(
|
||||
async (v, duration) => {
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const camera = window.__godsEyeView.engine.camera;
|
||||
camera.cancelFlight();
|
||||
await new Promise((resolve) =>
|
||||
camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(v.lon, v.lat, v.height),
|
||||
orientation: {
|
||||
heading: ((v.heading ?? 0) * Math.PI) / 180,
|
||||
pitch: ((v.pitch ?? -45) * Math.PI) / 180,
|
||||
roll: 0,
|
||||
},
|
||||
duration,
|
||||
maximumHeight: Math.max(v.height, camera.height()),
|
||||
complete: resolve,
|
||||
cancel: resolve,
|
||||
}),
|
||||
);
|
||||
},
|
||||
view,
|
||||
seconds,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await page.evaluate(
|
||||
(v, duration) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -191,7 +223,8 @@ export async function createScreencast(page, dir, options = {}) {
|
||||
const list = path.join(dir, 'frames.txt');
|
||||
fs.writeFileSync(list, `${lines.join('\n')}\n`);
|
||||
const clip = path.join(dir, 'clip.mp4');
|
||||
execFileSync('ffmpeg', [
|
||||
// Keep servicing Puppeteer's request interception while encoding.
|
||||
await execFileAsync('ffmpeg', [
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
|
||||
@@ -1086,15 +1086,25 @@ check({
|
||||
heavy: true, run: harness({ id: 'D1', script: 'track-regression.mjs', args: ['--url', APP_URL], timeoutMs: 1200000 }),
|
||||
});
|
||||
check({
|
||||
id: 'D2', group: 'D', desc: 'qa-heading-b3 — path-derived display heading',
|
||||
heavy: true, run: harness({ id: 'D2', script: 'qa-heading-b3.mjs', args: ['--url', APP_URL], timeoutMs: 900000 }),
|
||||
id: 'D2',
|
||||
group: 'D',
|
||||
desc: 'qa-aircraft-heading — path-derived display heading',
|
||||
heavy: true,
|
||||
run: harness({
|
||||
id: 'D2',
|
||||
script: 'qa-aircraft-heading.mjs',
|
||||
args: ['--url', APP_URL],
|
||||
timeoutMs: 900000,
|
||||
}),
|
||||
});
|
||||
check({
|
||||
id: 'D3', group: 'D', desc: 'qa-sprites-b5 — per-class billboard silhouettes',
|
||||
id: 'D3',
|
||||
group: 'D',
|
||||
desc: 'qa-aircraft-sprites — per-class billboard silhouettes',
|
||||
heavy: true,
|
||||
run: harness({
|
||||
id: 'D3',
|
||||
script: 'qa-sprites-b5.mjs',
|
||||
script: 'qa-aircraft-sprites.mjs',
|
||||
args: ['--url', APP_URL],
|
||||
timeoutMs: 900000,
|
||||
knownConditions: [{
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* legibility over bright ground.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after
|
||||
* node scripts/qa-label-readability.mjs --url http://localhost:4244 --tag after [--engine=cesium|navara]
|
||||
*
|
||||
* Engine-neutral: camera, tiles and layers go through the globe engine and the
|
||||
* QA probe; an engine without the flights/satellites layers shoots the
|
||||
* scenes without a contact field and says so.
|
||||
*
|
||||
* Every scene pins its own camera pose AND substitutes a deterministic
|
||||
* synthetic contact field for the live pollers, so a `before` run and an
|
||||
@@ -36,7 +40,12 @@ const getOpt = (name, fallback) => {
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4244');
|
||||
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const APP_URL = (() => {
|
||||
const url = new URL(getOpt('--url', 'http://localhost:4244'));
|
||||
url.searchParams.set('engine', ENGINE);
|
||||
return url.href;
|
||||
})();
|
||||
const TAG = getOpt('--tag', 'after');
|
||||
/** Comma-separated scene-id prefixes. Empty runs the whole set. */
|
||||
const SCENE_FILTER = getOpt('--scenes', '')
|
||||
@@ -205,11 +214,8 @@ async function settle(page) {
|
||||
while (Date.now() - started < SETTLE_MAX_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const state = await page.evaluate(() => {
|
||||
const { viewer, tileset } = window.__godsEyeView;
|
||||
return {
|
||||
tilesLoaded: tileset?.tilesLoaded !== false,
|
||||
pending: viewer.scene.globe?._surface?._tileLoadQueueHigh?.length ?? 0,
|
||||
};
|
||||
const { engine } = window.__godsEyeView;
|
||||
return { tilesLoaded: engine.surface.tilesLoaded(), pending: 0 };
|
||||
});
|
||||
const settled = state.tilesLoaded && state.pending === 0;
|
||||
if (settled && previous && Date.now() - started > SETTLE_MIN_MS) return true;
|
||||
@@ -239,7 +245,7 @@ async function main() {
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
args: [
|
||||
'--no-sandbox', '--disable-setuid-sandbox',
|
||||
'--use-gl=angle', '--use-angle=swiftshader',
|
||||
'--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage', '--disable-web-security',
|
||||
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
||||
'--window-size=1280,800',
|
||||
@@ -254,7 +260,7 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60_000, polling: 100 },
|
||||
);
|
||||
// flyToAustin arrives ~500 ms after init; let it start and land before any
|
||||
@@ -268,12 +274,16 @@ async function main() {
|
||||
|
||||
for (const scene of selected) {
|
||||
process.stdout.write(` ${scene.id} … `);
|
||||
await page.evaluate((spec) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const fieldShown = await page.evaluate(async (spec) => {
|
||||
const { engine, dataManager, styleManager, layers } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
const Cartesian3 = Object.assign(
|
||||
(x, y, z) => geo.cartesian3.create(x, y, z),
|
||||
{ fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) },
|
||||
);
|
||||
|
||||
viewer.camera.setView({
|
||||
engine.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(
|
||||
spec.camera.lon, spec.camera.lat, spec.camera.height,
|
||||
),
|
||||
@@ -290,10 +300,9 @@ async function main() {
|
||||
// the frame comes back with two labels instead of a field. Building
|
||||
// straight off the camera basis guarantees every contact lands inside
|
||||
// the keyhole, at a known depth, in both the before and after runs.
|
||||
const camera = viewer.camera;
|
||||
const frustum = camera.frustum;
|
||||
const halfHeight = Math.tan((frustum.fovy ?? frustum.fov ?? 1.0) / 2);
|
||||
const halfWidth = halfHeight * (frustum.aspectRatio || 1.6);
|
||||
const camera = engine.camera.pose();
|
||||
const halfHeight = Math.tan((camera.fovy ?? 1.0) / 2);
|
||||
const halfWidth = halfHeight * (camera.aspectRatio || 1.6);
|
||||
const basis = {
|
||||
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
|
||||
dx: camera.direction.x, dy: camera.direction.y, dz: camera.direction.z,
|
||||
@@ -305,7 +314,7 @@ async function main() {
|
||||
const pointAt = (ndcX, ndcY, distance) => {
|
||||
const sx = ndcX * halfWidth * distance;
|
||||
const sy = ndcY * halfHeight * distance;
|
||||
return new Cartesian3(
|
||||
return Cartesian3(
|
||||
basis.px + basis.dx * distance + basis.rx * sx + basis.ux * sy,
|
||||
basis.py + basis.dy * distance + basis.ry * sx + basis.uy * sy,
|
||||
basis.pz + basis.dz * distance + basis.rz * sx + basis.uz * sy,
|
||||
@@ -347,6 +356,9 @@ async function main() {
|
||||
? { satellites: build('s'), flights: [], military: [] }
|
||||
: { flights: build('f'), military: [], satellites: [] };
|
||||
window.__LABEL_EVIDENCE = assignment;
|
||||
const fieldLayers = Object.keys(assignment).filter(
|
||||
(layerId) => assignment[layerId].length,
|
||||
);
|
||||
for (const layerId of Object.keys(assignment)) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
if (entry?.module) {
|
||||
@@ -362,6 +374,7 @@ async function main() {
|
||||
densityPct: 100,
|
||||
allocationStrategy: 'elastic',
|
||||
});
|
||||
return fieldLayers.every((layerId) => layers.available(layerId));
|
||||
}, scene);
|
||||
|
||||
const converged = await settle(page);
|
||||
@@ -399,10 +412,11 @@ async function main() {
|
||||
const diagnostics = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getDetectionState?.() || {},
|
||||
);
|
||||
results.push({ scene: scene.id, converged, ...stats });
|
||||
results.push({ scene: scene.id, engine: ENGINE, converged, fieldShown, ...stats });
|
||||
console.log(
|
||||
`p05=${stats.p05} p25=${stats.p25} p50=${stats.p50} p95=${stats.p95}`
|
||||
+ `${converged ? '' : ' (tiles did not fully settle)'}`
|
||||
+ `${fieldShown ? '' : ` (no contact field: its layers are not on ${ENGINE})`}`
|
||||
+ ` [${diagnostics.detectionMode || '?'}]`,
|
||||
);
|
||||
}
|
||||
|
||||
+98
-24
@@ -3,7 +3,12 @@
|
||||
* Deterministic QA for the detection overlay's bounded two-lane label pipeline.
|
||||
*
|
||||
* Run:
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173
|
||||
* node scripts/qa-labels.mjs --url http://localhost:4173 [--engine=cesium|navara]
|
||||
*
|
||||
* Engine-neutral: the camera and frames go through the globe engine and the
|
||||
* layers through the QA probe (window.__godsEyeView.layers). On an engine
|
||||
* without the flights, military and satellites layers the field checks are
|
||||
* SKIPPED and the settings checks still run.
|
||||
*
|
||||
* The harness replaces only the three detection read APIs in the loaded page.
|
||||
* It does not enable their network pollers or mutate repository data. The field
|
||||
@@ -22,7 +27,12 @@ const getOpt = (name, fallback) => {
|
||||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||||
};
|
||||
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
const ENGINE = argv.find((value) => value.startsWith('--engine='))?.slice(9) ?? 'cesium';
|
||||
const APP_URL = (() => {
|
||||
const url = new URL(getOpt('--url', 'http://localhost:4173'));
|
||||
url.searchParams.set('engine', ENGINE);
|
||||
return url.href;
|
||||
})();
|
||||
const APP_ORIGIN = new URL(APP_URL).origin;
|
||||
const HEADFUL = getFlag('--headful');
|
||||
const SHOT_DIR = path.resolve('qa-shots/labels');
|
||||
@@ -74,6 +84,8 @@ function findChromeExecutable() {
|
||||
}
|
||||
|
||||
const results = [];
|
||||
let skippedChecks = 0;
|
||||
let skippedField = false;
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
@@ -214,7 +226,7 @@ async function main() {
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
`--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`,
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-background-timer-throttling',
|
||||
@@ -263,7 +275,7 @@ async function main() {
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.styleManager,
|
||||
() => window.__godsEyeView?.layers && window.__godsEyeView?.styleManager,
|
||||
{ timeout: 60000, polling: 100 },
|
||||
);
|
||||
|
||||
@@ -271,7 +283,7 @@ async function main() {
|
||||
// Let that callback start, then cancel it before establishing the global
|
||||
// measurement camera so startup motion cannot invalidate the sample.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
await page.evaluate(() => window.__godsEyeView.viewer.camera.cancelFlight());
|
||||
await page.evaluate(() => window.__godsEyeView.engine.camera.cancelFlight());
|
||||
|
||||
const stateChecks = await page.evaluate((storageKey) => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
@@ -297,10 +309,62 @@ async function main() {
|
||||
`OFF=${stateChecks.off.densityPct}%, restore=${stateChecks.restoredFromOff.detectionMode}/${stateChecks.restoredFromOff.densityPct}%`,
|
||||
);
|
||||
|
||||
const injected = await page.evaluate(({ fieldCounts, normalCounts }) => {
|
||||
const { viewer, dataManager, styleManager } = window.__godsEyeView;
|
||||
viewer.camera.cancelFlight();
|
||||
const Cartesian3 = viewer.camera.position.constructor;
|
||||
const fieldLayers = ['flights', 'military', 'satellites'];
|
||||
const missingLayers = await page.evaluate(
|
||||
(ids) => ids.filter((id) => !window.__godsEyeView.layers.available(id)),
|
||||
fieldLayers,
|
||||
);
|
||||
// An engine with flights but not the other field layers: the same 12,000 / 5,200 observations, all as aircraft, so the
|
||||
// detection lane is measured on the layers the engine has.
|
||||
let fieldCounts = FIELD_COUNTS;
|
||||
let normalCounts = NORMAL_COUNTS;
|
||||
if (missingLayers.length && !missingLayers.includes('flights')) {
|
||||
const sum = (counts) =>
|
||||
Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
fieldCounts = Object.fromEntries(
|
||||
fieldLayers.map((id) => [
|
||||
id,
|
||||
missingLayers.includes(id)
|
||||
? 0
|
||||
: id === 'flights'
|
||||
? sum(FIELD_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + FIELD_COUNTS[other], 0)
|
||||
: FIELD_COUNTS[id],
|
||||
]),
|
||||
);
|
||||
normalCounts = Object.fromEntries(
|
||||
fieldLayers.map((id) => [
|
||||
id,
|
||||
missingLayers.includes(id)
|
||||
? 0
|
||||
: id === 'flights'
|
||||
? sum(NORMAL_COUNTS) - fieldLayers.filter((other) => other !== 'flights' && !missingLayers.includes(other)).reduce((total, other) => total + NORMAL_COUNTS[other], 0)
|
||||
: NORMAL_COUNTS[id],
|
||||
]),
|
||||
);
|
||||
console.log(
|
||||
` [INFO] the field as ${JSON.stringify(fieldCounts)} (no ${missingLayers.join(', ')} on ${ENGINE})`,
|
||||
);
|
||||
} else if (missingLayers.length) {
|
||||
console.log(` [SKIP] the deterministic field checks — no ${missingLayers.join(', ')} on ${ENGINE}`);
|
||||
skippedChecks += 1;
|
||||
record(
|
||||
'console remains clean',
|
||||
consoleErrors.length === 0,
|
||||
consoleErrors.slice(0, 3).join(' | ') || '0 errors',
|
||||
);
|
||||
skippedField = true;
|
||||
return;
|
||||
}
|
||||
const injected = await page.evaluate(async ({ fieldCounts, normalCounts }) => {
|
||||
const { engine, dataManager, styleManager } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
engine.camera.cancelFlight();
|
||||
// A frame counter on the engine (the samples are one per drawn frame).
|
||||
window.__LABEL_QA_FRAME = 0;
|
||||
engine.frames.postRender.addEventListener(() => {
|
||||
window.__LABEL_QA_FRAME += 1;
|
||||
});
|
||||
const Cartesian3 = { fromDegrees: (lon, lat, height) => geo.cartesian3.fromDegrees(lon, lat, height) };
|
||||
const field = { flights: [], military: [], satellites: [] };
|
||||
const layerIds = Object.keys(field);
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
@@ -328,13 +392,19 @@ async function main() {
|
||||
window.__LABEL_QA_NORMAL_COUNTS = normalCounts;
|
||||
for (const layerId of layerIds) {
|
||||
const entry = dataManager.layers.get(layerId);
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||
if (entry)
|
||||
entry.module.getDetectableObjects = () => window.__LABEL_QA_FIELD[layerId];
|
||||
}
|
||||
|
||||
viewer.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(-97, 30, 25000000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
// The global camera, orbiting west to east (the old rotateRight about
|
||||
// the globe centre at 25 000 km).
|
||||
let orbitLon = -97;
|
||||
const placeCamera = () =>
|
||||
engine.camera.setView({
|
||||
destination: Cartesian3.fromDegrees(orbitLon, 30, 25000000),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
placeCamera();
|
||||
styleManager.setDetection({
|
||||
enabled: true,
|
||||
densityPct: 100,
|
||||
@@ -351,13 +421,14 @@ async function main() {
|
||||
let lastOrbitAt = performance.now();
|
||||
window.__LABEL_QA_ORBIT = setInterval(() => {
|
||||
const now = performance.now();
|
||||
viewer.camera.rotateRight((now - lastOrbitAt) * 0.0000012);
|
||||
orbitLon -= ((now - lastOrbitAt) * 0.0000012 * 180) / Math.PI;
|
||||
lastOrbitAt = now;
|
||||
viewer.scene.requestRender();
|
||||
placeCamera();
|
||||
engine.render.request('qa-labels');
|
||||
}, 100);
|
||||
const sample = () => {
|
||||
const diagnostics = styleManager.getDetectionDiagnostics();
|
||||
const frameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
const frameNumber = window.__LABEL_QA_FRAME;
|
||||
if (diagnostics && frameNumber !== window.__LABEL_QA.lastFrameNumber) {
|
||||
window.__LABEL_QA.lastFrameNumber = frameNumber;
|
||||
window.__LABEL_QA.frames.push({
|
||||
@@ -387,7 +458,7 @@ async function main() {
|
||||
observationCount: Number(dataset.observationCount),
|
||||
selectedCount: Number(dataset.selectedCount),
|
||||
fadingCount: Number(dataset.fadingCount),
|
||||
cameraHeight: viewer.camera.positionCartographic.height,
|
||||
cameraHeight: engine.camera.height(),
|
||||
demandByLayer: JSON.parse(dataset.demandByLayer || '{}'),
|
||||
cohortByLayer: JSON.parse(dataset.cohortByLayer || '{}'),
|
||||
entitlementByLayer: JSON.parse(dataset.entitlementByLayer || '{}'),
|
||||
@@ -398,7 +469,7 @@ async function main() {
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
return Object.fromEntries(layerIds.map((layerId) => [layerId, field[layerId].length]));
|
||||
}, { fieldCounts: FIELD_COUNTS, normalCounts: NORMAL_COUNTS });
|
||||
}, { fieldCounts, normalCounts });
|
||||
|
||||
record(
|
||||
'deterministic field contains exactly 12,000 observations',
|
||||
@@ -412,12 +483,11 @@ async function main() {
|
||||
// -samples gates supersede it — same fix, deterministic instead of timed.)
|
||||
await waitForSettledSyntheticField(page);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const syntheticSampling = await waitForConclusiveSamples(page, {
|
||||
@@ -452,12 +522,11 @@ async function main() {
|
||||
{ timeout: SAMPLE_TIMEOUT_MS, polling: 100 },
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const revision = Number(document.querySelector('#world-overlay-canvas')?.dataset?.solveRevision || 0);
|
||||
window.__LABEL_QA.solve = [];
|
||||
window.__LABEL_QA.frames = [];
|
||||
window.__LABEL_QA.lastRevision = revision;
|
||||
window.__LABEL_QA.lastFrameNumber = viewer.scene.frameState?.frameNumber ?? -1;
|
||||
window.__LABEL_QA.lastFrameNumber = window.__LABEL_QA_FRAME;
|
||||
window.__LABEL_QA.startedAt = performance.now();
|
||||
});
|
||||
const normalSampling = await waitForConclusiveSamples(page, {
|
||||
@@ -579,10 +648,15 @@ async function main() {
|
||||
console.log(` Synthetic solves: ${solveSamples.length}; normal frames: ${normalFieldFrames.length} (${normalFrames.length} non-solve)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (skippedField) summarize();
|
||||
}
|
||||
|
||||
summarize();
|
||||
}
|
||||
|
||||
function summarize() {
|
||||
const failures = results.filter((result) => !result.ok);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
||||
console.log(`\n${results.length - failures.length}/${results.length} checks passed${skippedChecks ? `, ${skippedChecks} skipped` : ''} (engine ${ENGINE}).`);
|
||||
if (failures.length) process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const url = localPreview(process.argv[2]);
|
||||
const military = process.argv.includes('--military');
|
||||
const layerId = military ? 'military' : 'flights';
|
||||
const aircraftId = military ? 'aaa052' : '71be26';
|
||||
url.search = '?engine=navara&welcome=0';
|
||||
url.hash =
|
||||
'v=2&lat=40.2539&lon=-125.5779&alt=21130&heading=360&pitch=-35&map=sentinel2' +
|
||||
(military ? '&l=m&lo=f.e.1_f.u.aaa052' : '&l=f&lo=f.e.1_f.t.71be26');
|
||||
// The Command Dock, and the Globe Engine picker in it, is hidden in cockpit
|
||||
// mode: no person can pick an engine there, so the cockpit switches below go
|
||||
// through the select element; the real picker is used once the cockpit is
|
||||
// left.
|
||||
const HIDDEN_PICKER = 'the Globe Engine picker is hidden in cockpit mode';
|
||||
const strict = createStrict({ origin: url.origin, layers: [layerId] });
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
await page.evaluateOnNewDocument(
|
||||
(layerId, aircraftId) => {
|
||||
window.__qaAircraft = { layerId, aircraftId };
|
||||
const fetch = window.fetch.bind(window);
|
||||
window.__flightPolls = 0;
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = function (callback, delay, ...args) {
|
||||
if (typeof callback !== 'function')
|
||||
return interval(callback, delay, ...args);
|
||||
const id = interval(
|
||||
function (...values) {
|
||||
if (window.__handoff?.timer === id) window.__handoff.ticks++;
|
||||
return callback.apply(this, values);
|
||||
},
|
||||
delay,
|
||||
...args,
|
||||
);
|
||||
return id;
|
||||
};
|
||||
window.fetch = (input, options) => {
|
||||
const path = new URL(input.url ?? input, document.baseURI).pathname;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (path === '/api/flights') {
|
||||
if (layerId === 'flights') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
time: now,
|
||||
states: [
|
||||
[
|
||||
'71be26',
|
||||
'GEV123',
|
||||
'QA',
|
||||
now,
|
||||
now,
|
||||
-125.5779,
|
||||
40.2539,
|
||||
9700,
|
||||
false,
|
||||
200,
|
||||
45,
|
||||
0,
|
||||
null,
|
||||
9700,
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/military') {
|
||||
if (layerId === 'military') window.__flightPolls++;
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
now: Date.now(),
|
||||
ac: [
|
||||
{
|
||||
hex: 'aaa052',
|
||||
flight: 'GEVMIL',
|
||||
lon: -125.5779,
|
||||
lat: 40.2539,
|
||||
alt_geom: 31825,
|
||||
alt_baro: 31825,
|
||||
gs: 389,
|
||||
track: 45,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
dbFlags: 1,
|
||||
type: 'adsb_icao',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === '/api/flights/track')
|
||||
return Promise.resolve(Response.json({ path: [] }));
|
||||
if (path.startsWith('/api/flights/'))
|
||||
return Promise.resolve(Response.json({}));
|
||||
return fetch(input, options);
|
||||
};
|
||||
},
|
||||
layerId,
|
||||
aircraftId,
|
||||
);
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
const ready = (kind) =>
|
||||
page.waitForFunction(
|
||||
(engine) =>
|
||||
window.__godsEyeView?.engine?.kind === engine &&
|
||||
!document.querySelector(
|
||||
'#globe-engine:is([disabled], [aria-busy="true"])',
|
||||
) &&
|
||||
document.querySelector('#loading-screen.hidden') &&
|
||||
!document.querySelector('.engine-switch-frame'),
|
||||
{ timeout: 90000 },
|
||||
kind,
|
||||
);
|
||||
await ready('navara');
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
await page.waitForSelector('#cockpit-entry', { visible: true });
|
||||
await page.click('#cockpit-entry');
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.getCockpitState().active,
|
||||
);
|
||||
await assertKeylessPreview(page);
|
||||
await page.evaluate(() => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
|
||||
styleManager.cockpitView.setVisionMode('thermal');
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
window.__handoff = {
|
||||
dataManager,
|
||||
entry,
|
||||
row: document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
timer: entry.intervalId,
|
||||
ticks: 0,
|
||||
model: entry.module,
|
||||
pollCount: window.__flightPolls,
|
||||
timestamp: entry.module.getStats().lastUpdate,
|
||||
};
|
||||
});
|
||||
// Refuse the first Cesium canvas. The application must roll back with the
|
||||
// original aircraft still in the cockpit, then allow an ordinary retry.
|
||||
await page.evaluate(() => {
|
||||
const original = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, ...args) {
|
||||
if (String(type).includes('webgl')) {
|
||||
window.__refusedContext = true;
|
||||
throw new Error('QA renderer startup failure');
|
||||
}
|
||||
return original.call(this, type, ...args);
|
||||
};
|
||||
window.__restoreContext = () => {
|
||||
HTMLCanvasElement.prototype.getContext = original;
|
||||
};
|
||||
});
|
||||
const historyLength = await page.evaluate(() => history.length);
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await page.waitForFunction(() => window.__refusedContext === true);
|
||||
await ready('navara');
|
||||
// A failed switch leaves the session usable on the engine that worked and
|
||||
// adds no history entry.
|
||||
const survived = (kind) =>
|
||||
page.evaluate(
|
||||
(kind, length) => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine.kind === kind &&
|
||||
new URL(location.href).searchParams.get('engine') === kind &&
|
||||
history.length === length &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||
window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getCockpitState().visionMode === 'thermal' &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
},
|
||||
kind,
|
||||
historyLength,
|
||||
);
|
||||
await page.evaluate(() => window.__restoreContext());
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'cold Cesium failure rolls back to Navara: same session, cockpit, aircraft, no history entry',
|
||||
await survived('navara'),
|
||||
);
|
||||
console.log(
|
||||
'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history',
|
||||
);
|
||||
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await strict.selectEngine(page, kind, HIDDEN_PICKER);
|
||||
await ready(kind);
|
||||
const result = await page.evaluate(() => {
|
||||
const { dataManager, engine, styleManager } = window.__godsEyeView;
|
||||
const before = window.__handoff;
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
const cockpit = styleManager.getCockpitState();
|
||||
return {
|
||||
sameOwner:
|
||||
before.dataManager === dataManager &&
|
||||
before.entry === entry &&
|
||||
before.model === entry.module,
|
||||
sameRow:
|
||||
Boolean(before.row) &&
|
||||
before.row ===
|
||||
document.querySelector(
|
||||
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
|
||||
),
|
||||
sameTimer:
|
||||
before.timer === entry.intervalId && entry.intervalId != null,
|
||||
sourcePolls: window.__flightPolls - before.pollCount,
|
||||
timerTicks: before.ticks,
|
||||
stamp: entry.module.getStats().lastUpdate,
|
||||
initialStamp: before.timestamp,
|
||||
id: cockpit.subject?.id,
|
||||
active: cockpit.active,
|
||||
mode: cockpit.visionMode,
|
||||
hud: !document.querySelector('#cockpit-hud').hidden,
|
||||
drawn: entry.module
|
||||
.qaDrawn()
|
||||
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
height: engine.camera.height(),
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
result.sameOwner &&
|
||||
result.sameTimer &&
|
||||
result.sameRow &&
|
||||
result.active &&
|
||||
result.hud &&
|
||||
result.drawn,
|
||||
JSON.stringify(result),
|
||||
);
|
||||
assert.equal(result.id, aircraftId);
|
||||
assert.equal(result.mode, 'thermal');
|
||||
assert.ok(
|
||||
result.sourcePolls <= result.timerTicks,
|
||||
'switch must not add a source request',
|
||||
);
|
||||
if (result.timerTicks === 0) {
|
||||
assert.equal(result.stamp, result.initialStamp);
|
||||
}
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'same manager entry, model, row, poll timer, cockpit, thermal mode, HUD and drawn aircraft',
|
||||
result,
|
||||
);
|
||||
// Polling goes on after the switch: the same timer ticks and each tick
|
||||
// reads the source once (no tick may be skipped, none added).
|
||||
const polls = await page.evaluate(async (layerId) => {
|
||||
const entry = window.__godsEyeView.dataManager.layers.get(layerId);
|
||||
const every =
|
||||
Number(entry.module.refreshInterval) ||
|
||||
Number(entry.module.updateInterval);
|
||||
const start = {
|
||||
polls: window.__flightPolls,
|
||||
ticks: window.__handoff.ticks,
|
||||
stamp: entry.module.getStats().lastUpdate,
|
||||
};
|
||||
const deadline = Date.now() + 2.5 * every + 5000;
|
||||
while (window.__handoff.ticks - start.ticks < 2 && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
// Let the last tick's request land.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
every,
|
||||
ticks: window.__handoff.ticks - start.ticks,
|
||||
polls: window.__flightPolls - start.polls,
|
||||
updated: entry.module.getStats().lastUpdate !== start.stamp,
|
||||
sameTimer: entry.intervalId === window.__handoff.timer,
|
||||
};
|
||||
}, layerId);
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'polling continues after the switch: two timer ticks, one source read each, fresh data',
|
||||
polls.every > 0 &&
|
||||
polls.ticks >= 2 &&
|
||||
polls.polls === polls.ticks &&
|
||||
polls.updated &&
|
||||
polls.sameTimer,
|
||||
polls,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => Math.abs(window.__godsEyeView.engine.camera.height() - 9700) < 1000,
|
||||
);
|
||||
console.log(
|
||||
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`,
|
||||
);
|
||||
}
|
||||
// The other direction, warm: the parked Navara renderer fails to resume on
|
||||
// the way back from Cesium. The session rolls back to Cesium, then an
|
||||
// ordinary retry starts Navara cold.
|
||||
await page.evaluate(() => {
|
||||
window.__parkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await ready('cesium');
|
||||
const lengthOnCesium = await page.evaluate(() => {
|
||||
const request = window.__parkedNavara.render.request;
|
||||
window.__parkedNavara.render.request = function (...args) {
|
||||
window.__parkedNavara.render.request = request;
|
||||
window.__refusedResume = true;
|
||||
throw new Error('QA parked renderer failure');
|
||||
};
|
||||
return history.length;
|
||||
});
|
||||
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||
await page.waitForFunction(() => window.__refusedResume === true);
|
||||
await ready('cesium');
|
||||
assert.ok(
|
||||
(await page.evaluate(
|
||||
(length) => history.length === length,
|
||||
lengthOnCesium,
|
||||
)) &&
|
||||
(await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId &&
|
||||
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||
);
|
||||
})),
|
||||
'a failed warm Navara resume must leave Cesium usable without a history entry',
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'warm Navara failure rolls back to Cesium: same aircraft and cockpit, no history entry',
|
||||
);
|
||||
console.log(
|
||||
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__parkedCesium = window.__godsEyeView.engine;
|
||||
});
|
||||
await strict.selectEngine(page, 'navara', HIDDEN_PICKER);
|
||||
await ready('navara');
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
engine !== window.__parkedNavara &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId
|
||||
);
|
||||
}),
|
||||
'the retry starts a new Navara renderer on the same session',
|
||||
);
|
||||
console.log('PASS retry after the warm failure started Navara cold');
|
||||
|
||||
// Both engines fail: the warm Cesium resume and the warm Navara recovery.
|
||||
// No engine runs, but the error screen offers the engine picker, and a
|
||||
// retry starts Cesium cold on the same session without a reload.
|
||||
const lengthBeforeDoubleFailure = await page.evaluate(() => {
|
||||
window.__failResume = true;
|
||||
for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) {
|
||||
const request = engine.render.request;
|
||||
engine.render.request = function (reason, ...args) {
|
||||
if (reason === 'engine-resume' && window.__failResume) {
|
||||
window.__refusedResumes = (window.__refusedResumes ?? 0) + 1;
|
||||
throw new Error('QA parked renderer failure');
|
||||
}
|
||||
return request.call(this, reason, ...args);
|
||||
};
|
||||
}
|
||||
window.__startDocument = document;
|
||||
return history.length;
|
||||
});
|
||||
await strict.selectEngine(page, 'cesium', HIDDEN_PICKER);
|
||||
await page
|
||||
.waitForSelector('#loading-screen .engine-switch-retry button', {
|
||||
visible: true,
|
||||
timeout: 90000,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.log(
|
||||
'no retry picker',
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
refused: window.__refusedResumes,
|
||||
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||
retry: !!document.querySelector('.engine-switch-retry'),
|
||||
loading: document.querySelector('#loading-screen')?.className,
|
||||
status: document.querySelector('#loading-screen .loader-status')
|
||||
?.textContent,
|
||||
address: location.search,
|
||||
})),
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
(length) =>
|
||||
window.__refusedResumes === 2 &&
|
||||
// No globe runs (the page's debug object may stay, without one).
|
||||
!window.__godsEyeView?.engine &&
|
||||
history.length === length &&
|
||||
!document
|
||||
.querySelector('#loading-screen')
|
||||
.classList.contains('hidden') &&
|
||||
/QA parked renderer failure/.test(
|
||||
document.querySelector('#loading-screen .loader-status').textContent,
|
||||
),
|
||||
lengthBeforeDoubleFailure,
|
||||
),
|
||||
'a failed recovery shows its error and the engine picker ' +
|
||||
JSON.stringify(
|
||||
await page.evaluate(() => ({
|
||||
refused: window.__refusedResumes,
|
||||
app: !!window.__godsEyeView,
|
||||
engine: window.__godsEyeView?.engine?.kind ?? null,
|
||||
history: history.length,
|
||||
loading: document.querySelector('#loading-screen')?.className,
|
||||
status: document.querySelector('#loading-screen .loader-status')
|
||||
?.textContent,
|
||||
})),
|
||||
),
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__failResume = false;
|
||||
});
|
||||
await page.click(
|
||||
'#loading-screen .engine-switch-retry [data-engine="cesium"]',
|
||||
);
|
||||
await ready('cesium');
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const { dataManager, styleManager } = window.__godsEyeView;
|
||||
return (
|
||||
window.__startDocument === document &&
|
||||
!document.querySelector('.engine-switch-retry') &&
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||
window.__handoff.entry &&
|
||||
styleManager.getCockpitState().active &&
|
||||
styleManager.getAircraftTrackingTarget()?.id ===
|
||||
window.__qaAircraft.aircraftId &&
|
||||
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||
);
|
||||
}),
|
||||
'the retry restores the same session on Cesium',
|
||||
);
|
||||
await strict.passed(
|
||||
page,
|
||||
layerId,
|
||||
'after both engines fail, the retry restores the same session',
|
||||
);
|
||||
console.log(
|
||||
'PASS failed recovery offered the picker; the retry restored the session on Cesium',
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.styleManager.getCockpitState().active,
|
||||
);
|
||||
// Out of the cockpit the dock shows the picker again: a switch with
|
||||
// real input keeps the same entry and timer, the aircraft drawn, and the
|
||||
// source polled on its timer.
|
||||
await page.evaluate(() => {
|
||||
window.__handoff.ticks = 0;
|
||||
window.__handoff.pollCount = window.__flightPolls;
|
||||
window.__handoff.timer = window.__godsEyeView.dataManager.layers.get(
|
||||
window.__qaAircraft.layerId,
|
||||
).intervalId;
|
||||
});
|
||||
await strict.pick(page, 'navara');
|
||||
await ready('navara');
|
||||
const after = await page.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
const entry = dataManager.layers.get(window.__qaAircraft.layerId);
|
||||
const every =
|
||||
Number(entry.module.refreshInterval) ||
|
||||
Number(entry.module.updateInterval);
|
||||
const deadline = Date.now() + 2.5 * every + 5000;
|
||||
while (window.__handoff.ticks < 1 && Date.now() < deadline)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return {
|
||||
same:
|
||||
dataManager === window.__handoff.dataManager &&
|
||||
entry === window.__handoff.entry,
|
||||
timer: entry.intervalId === window.__handoff.timer,
|
||||
drawn: entry.module
|
||||
.qaDrawn()
|
||||
.some((row) => row.id === window.__qaAircraft.aircraftId),
|
||||
ticks: window.__handoff.ticks,
|
||||
polls: window.__flightPolls - window.__handoff.pollCount,
|
||||
};
|
||||
});
|
||||
await strict.check(
|
||||
page,
|
||||
layerId,
|
||||
'picker switch keeps the entry, timer and drawn aircraft; polling continues',
|
||||
after.same &&
|
||||
after.timer &&
|
||||
after.drawn &&
|
||||
after.ticks >= 1 &&
|
||||
after.polls === after.ticks,
|
||||
after,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
openPage,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0&look=cinematic';
|
||||
base.hash =
|
||||
'v=2&lat=41.5340&lon=-90.5070&alt=20000&heading=0&pitch=-90&roll=0&map=sentinel2&l=';
|
||||
const strict = createStrict({ origin: base.origin, layers: ['local-adsb'] });
|
||||
await strict.run({}, async (b) => {
|
||||
const { page: p, errors } = await openPage(b);
|
||||
await p.evaluateOnNewDocument(() => {
|
||||
const raw = window.fetch;
|
||||
window.fetch = function (input, ...args) {
|
||||
const url = new URL(input?.url ?? input, location.href);
|
||||
if (url.pathname === '/api/local-receivers/aircraft') {
|
||||
const at = Date.now();
|
||||
// Each poll reports the aircraft a little further north.
|
||||
const n = (window.qaAdsbPolls = (window.qaAdsbPolls ?? 0) + 1);
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
configured: true,
|
||||
generatedAt: at,
|
||||
records: [
|
||||
{
|
||||
icao: 'abc123',
|
||||
callsign: 'LOCAL1',
|
||||
lat: 41.534 + 0.0002 * n,
|
||||
lon: -90.507,
|
||||
altitudeFt: 11000,
|
||||
groundSpeedKt: 180,
|
||||
trackDeg: 5,
|
||||
lastPositionAt: at,
|
||||
lastMessageAt: at,
|
||||
messageCount: 12 + n,
|
||||
band: '1090',
|
||||
source: 'decoder',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return raw.call(this, input, ...args);
|
||||
};
|
||||
window.qaSelections = [];
|
||||
window.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
window.qaSelections.push(detail.id),
|
||||
);
|
||||
});
|
||||
await p.goto(base.href);
|
||||
await waitForEngine(p, 'navara');
|
||||
await p.evaluate(async () => {
|
||||
const { dataManager } = window.__godsEyeView;
|
||||
|
||||
const mod = dataManager.layers.get('local-adsb').module;
|
||||
window.qaAdsb = {
|
||||
mod,
|
||||
receiver: mod.receiver,
|
||||
feeds: mod.feeds,
|
||||
starts: 0,
|
||||
stops: 0,
|
||||
closes: 0,
|
||||
};
|
||||
for (const [obj, key, counter] of [
|
||||
[mod.receiver, 'destroy', 'closes'],
|
||||
[mod.feeds, 'start', 'starts'],
|
||||
[mod.feeds, 'stop', 'stops'],
|
||||
]) {
|
||||
const original = obj[key].bind(obj);
|
||||
obj[key] = (...args) => {
|
||||
window.qaAdsb[counter]++;
|
||||
return original(...args);
|
||||
};
|
||||
}
|
||||
await dataManager.setEnabled('local-adsb', true, { origin: 'user' });
|
||||
});
|
||||
await p.waitForFunction(
|
||||
() => window.qaAdsb.mod.getAllPositions().length === 1,
|
||||
);
|
||||
// The selection hook the selectable layers share, by the row's own id.
|
||||
assert.ok(
|
||||
await p.evaluate(() =>
|
||||
window.qaAdsb.mod.selectById(window.qaAdsb.mod.getAllPositions()[0].id),
|
||||
),
|
||||
);
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
await p.evaluate(() => {
|
||||
window.qaSelections = [];
|
||||
});
|
||||
await strict.switchTo(p, kind);
|
||||
await p.waitForFunction(() =>
|
||||
window.qaSelections.includes('local-adsb:abc123'),
|
||||
);
|
||||
const result = await p.evaluate(() => {
|
||||
const q = window.qaAdsb,
|
||||
manager = window.__godsEyeView.dataManager;
|
||||
const mod = manager.layers.get('local-adsb').module;
|
||||
return [
|
||||
manager.isEnabled(mod.id),
|
||||
q.mod === mod,
|
||||
q.receiver === mod.receiver,
|
||||
q.feeds === mod.feeds,
|
||||
mod.getAllPositions()[0]?.id,
|
||||
q.starts,
|
||||
q.stops,
|
||||
q.closes,
|
||||
];
|
||||
});
|
||||
assert.deepEqual(result, [true, true, true, true, 'abc123', 1, 0, 0]);
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-adsb',
|
||||
'same decoder, receiver and feeds, one feed start, selection republished',
|
||||
);
|
||||
// Updates continue on the new engine: further polls move the aircraft.
|
||||
const before = await p.evaluate(() => ({
|
||||
polls: window.qaAdsbPolls,
|
||||
lat: window.qaAdsb.mod.getAnalystRecords()[0]?.lat,
|
||||
stamp: window.qaAdsb.mod.getStats().lastUpdate,
|
||||
}));
|
||||
await p.waitForFunction(
|
||||
(before) =>
|
||||
window.qaAdsbPolls >= before.polls + 2 &&
|
||||
window.qaAdsb.mod.getAnalystRecords()[0]?.lat > before.lat &&
|
||||
window.qaAdsb.mod.getStats().lastUpdate > before.stamp,
|
||||
{ timeout: 15000 },
|
||||
before,
|
||||
);
|
||||
await strict.passed(
|
||||
p,
|
||||
'local-adsb',
|
||||
'receiver polls continue and move the selected aircraft',
|
||||
before,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS retained local decoder, receiver, marker and selection in both scenes',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Install before navigation, without intercepting workers or ordinary tiles. */
|
||||
export async function blockPaidQaMaps(page, { allowPaidMaps = false } = {}) {
|
||||
if (allowPaidMaps) return;
|
||||
const session = await page.createCDPSession();
|
||||
await session.send('Network.enable');
|
||||
await session.send('Network.setBlockedURLs', {
|
||||
urls: [
|
||||
'*://tile.googleapis.com/*',
|
||||
'*://*.googleapis.com/*3dtiles*',
|
||||
'*://api.cesium.com/v1/assets/2275207/*',
|
||||
'*/api/google/tiles*',
|
||||
],
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free public tile hosts the strict acceptance lets a page ask for. The page
|
||||
* never reaches them: the strict harness answers them from a disk cache
|
||||
* (scripts/qa/tileCache.mjs), and Node fetches a missing tile once, politely.
|
||||
* OpenStreetMap raster tiles, the keyless re:earth terrain, the EOX
|
||||
* Sentinel-2 cloudless mosaic and Esri World Imagery (the keyless Cesium
|
||||
* base imagery and the Navara startup imagery).
|
||||
*/
|
||||
export const STRICT_TILE_HOSTS = Object.freeze([
|
||||
'tile.openstreetmap.org',
|
||||
'terrain.reearth.land',
|
||||
'tiles.maps.eox.at',
|
||||
'services.arcgisonline.com',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Requests every keyless page makes on its own, answered by the strict
|
||||
* harness exactly as the keyless preview would (or with an empty stylesheet
|
||||
* for Google Fonts), so they reach neither the preview's paid routes nor
|
||||
* Google. Any other request under a paid route or host stays blocked.
|
||||
* Keys are URLPattern strings relative to the preview origin or absolute.
|
||||
*/
|
||||
export const KEYLESS_PROBES = Object.freeze([
|
||||
{
|
||||
pattern: '/api/openai/hud-summary/status',
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{"configured":false}',
|
||||
},
|
||||
{ pattern: '/api/google/tiles-token', method: 'GET', status: 204 },
|
||||
{ pattern: '/api/realtime/debug-log', method: 'POST', status: 204 },
|
||||
{
|
||||
pattern: 'https://fonts.googleapis.com/css2*',
|
||||
method: 'GET',
|
||||
status: 200,
|
||||
contentType: 'text/css',
|
||||
body: '/* Google Fonts withheld by the strict QA policy */',
|
||||
},
|
||||
]);
|
||||
|
||||
/** A probe's absolute URLPattern string. */
|
||||
export const probePattern = (origin, probe) =>
|
||||
probe.pattern.startsWith('/')
|
||||
? new URL(origin).origin + probe.pattern
|
||||
: probe.pattern;
|
||||
|
||||
/** Paid or keyed services: blocked first, whatever else a scenario allows. */
|
||||
export const PAID_HOST_PATTERNS = Object.freeze([
|
||||
'*://*.googleapis.com/*',
|
||||
'*://googleapis.com/*',
|
||||
'*://*.google.com/*',
|
||||
'*://google.com/*',
|
||||
'*://maps.google.*/*',
|
||||
'*://*.gstatic.com/*',
|
||||
'*://*.cesium.com/*',
|
||||
'*://cesium.com/*',
|
||||
]);
|
||||
|
||||
/** Preview routes that spend a key (OpenAI, Realtime, Google proxies). */
|
||||
export const PAID_PREVIEW_PATHS = Object.freeze([
|
||||
'/api/openai/*',
|
||||
'/api/realtime/*',
|
||||
'/api/google/*',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The ordered Network.setBlockedURLs patterns of the strict policy (first
|
||||
* match wins): the keyless probes the harness answers are let through to
|
||||
* it, paid hosts and paid preview routes are blocked, then the
|
||||
* preview origin, the scenario's fixture URLs and the free tile hosts are
|
||||
* allowed, then everything else is blocked.
|
||||
*
|
||||
* @param {string} origin The preview origin, e.g. http://127.0.0.1:4208
|
||||
* @param {{fixtures?: string[], tileHosts?: readonly string[]}} [options]
|
||||
* `fixtures` are URLPattern strings for requests the scenario answers.
|
||||
*/
|
||||
export function strictBlockPatterns(
|
||||
origin,
|
||||
{ fixtures = [], tileHosts = STRICT_TILE_HOSTS } = {},
|
||||
) {
|
||||
const preview = new URL(origin).origin;
|
||||
return [
|
||||
...KEYLESS_PROBES.map((probe) => ({
|
||||
urlPattern: probePattern(origin, probe),
|
||||
block: false,
|
||||
})),
|
||||
...PAID_HOST_PATTERNS.map((urlPattern) => ({ urlPattern, block: true })),
|
||||
...PAID_PREVIEW_PATHS.map((path) => ({
|
||||
urlPattern: preview + path,
|
||||
block: true,
|
||||
})),
|
||||
{ urlPattern: preview + '/*', block: false },
|
||||
...fixtures.map((urlPattern) => ({ urlPattern, block: false })),
|
||||
...tileHosts.map((host) => ({
|
||||
urlPattern: `https://${host}/*`,
|
||||
block: false,
|
||||
})),
|
||||
{ urlPattern: '*://*:*/*', block: true },
|
||||
];
|
||||
}
|
||||
|
||||
/** Paid or live model endpoints no switch gate may reach. */
|
||||
export const SESSION_BLOCKED_URLS = Object.freeze([
|
||||
'*://tile.googleapis.com/*',
|
||||
'*://*.googleapis.com/*3dtiles*',
|
||||
'*://api.cesium.com/*',
|
||||
'*://assets.ion.cesium.com/*',
|
||||
'*://maps.googleapis.com/*',
|
||||
'*/api/google/*',
|
||||
'*/api/openai/*',
|
||||
'*/api/realtime/*',
|
||||
'*/api/gemini/*',
|
||||
'*://api.openai.com/*',
|
||||
'*://generativelanguage.googleapis.com/*',
|
||||
]);
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
blockPaidQaMaps,
|
||||
KEYLESS_PROBES,
|
||||
probePattern,
|
||||
strictBlockPatterns,
|
||||
} from './qa-map-policy.mjs';
|
||||
|
||||
test('routine QA blocks direct Google tiles, hosted Google assets and token fallback', async () => {
|
||||
const calls = [];
|
||||
const page = {
|
||||
createCDPSession: async () => ({
|
||||
send: async (...args) => calls.push(args),
|
||||
}),
|
||||
};
|
||||
await blockPaidQaMaps(page);
|
||||
assert.equal(calls[0][0], 'Network.enable');
|
||||
assert.equal(calls[1][0], 'Network.setBlockedURLs');
|
||||
assert.ok(calls[1][1].urls.includes('*://tile.googleapis.com/*'));
|
||||
assert.ok(
|
||||
calls[1][1].urls.includes('*://api.cesium.com/v1/assets/2275207/*'),
|
||||
);
|
||||
assert.ok(calls[1][1].urls.includes('*/api/google/tiles*'));
|
||||
});
|
||||
|
||||
test('a necessary 3D-specific check must explicitly opt out of the request block', async () => {
|
||||
await blockPaidQaMaps(
|
||||
{ createCDPSession: () => assert.fail('unexpected block') },
|
||||
{ allowPaidMaps: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('the strict policy blocks paid hosts and routes first and everything unlisted last', () => {
|
||||
const patterns = strictBlockPatterns('http://127.0.0.1:4208', {
|
||||
fixtures: ['https://tiles.dontgetflocked.com/*'],
|
||||
});
|
||||
const decide = (url) =>
|
||||
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||
?.block;
|
||||
for (const url of [
|
||||
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||
'https://maps.googleapis.com/maps/api/js',
|
||||
'https://fonts.gstatic.com/s/inter.woff2',
|
||||
'https://api.cesium.com/v1/assets/1/endpoint',
|
||||
'https://assets.ion.cesium.com/google-credit.png',
|
||||
'http://127.0.0.1:4208/api/openai/hud-summary',
|
||||
'http://127.0.0.1:4208/api/realtime/token',
|
||||
'http://127.0.0.1:4208/api/google/nearby-places?lat=1',
|
||||
'https://example.com/anything',
|
||||
'wss://example.com/socket',
|
||||
])
|
||||
assert.equal(decide(url), true, url);
|
||||
for (const url of [
|
||||
'http://127.0.0.1:4208/assets/index.js',
|
||||
'http://127.0.0.1:4208/api/flights',
|
||||
'https://tiles.dontgetflocked.com/cameras-us-hourly/1/2/3.mvt',
|
||||
'https://tile.openstreetmap.org/1/2/3.png',
|
||||
'https://terrain.reearth.land/cesium-mesh/layer.json',
|
||||
'https://tiles.maps.eox.at/wmts/1.0.0/s2/1/2/3.jpg',
|
||||
])
|
||||
assert.equal(decide(url), false, url);
|
||||
});
|
||||
|
||||
test('the keyless probes are let through only to their exact routes', () => {
|
||||
const patterns = strictBlockPatterns('http://127.0.0.1:4208');
|
||||
const decide = (url) =>
|
||||
patterns.find(({ urlPattern }) => new URLPattern(urlPattern).test(url))
|
||||
?.block;
|
||||
for (const probe of KEYLESS_PROBES) {
|
||||
const url = probePattern('http://127.0.0.1:4208', probe).replace(
|
||||
/\*$/,
|
||||
'?family=Inter',
|
||||
);
|
||||
assert.equal(decide(url), false, url);
|
||||
}
|
||||
assert.equal(
|
||||
decide('http://127.0.0.1:4208/api/openai/hud-summary'),
|
||||
true,
|
||||
'the paid summary itself stays blocked',
|
||||
);
|
||||
assert.equal(decide('https://fonts.googleapis.com/icon?family=x'), true);
|
||||
});
|
||||
@@ -316,9 +316,9 @@ try {
|
||||
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
|
||||
}));
|
||||
check(
|
||||
'exact five-source presentation; the retired left Map Stack panel is gone',
|
||||
'exact six-source presentation; the retired left Map Stack panel is gone',
|
||||
JSON.stringify(presentation.ids) === JSON.stringify([
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
|
||||
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'sentinel2', 'osm',
|
||||
]) && !presentation.retiredPanel,
|
||||
JSON.stringify(presentation),
|
||||
);
|
||||
@@ -338,12 +338,16 @@ try {
|
||||
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
const provider = controller._activeImageryProvider;
|
||||
// The base imagery the controller draws now (it is handed to the globe
|
||||
// engine, src/maps/controller.js).
|
||||
const liveProvider = () =>
|
||||
controller._liveBaseProvider?.() ?? controller._activeImageryProvider;
|
||||
const provider = liveProvider();
|
||||
const before = {
|
||||
activeId: controller.getActiveId(),
|
||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||
globeShown: styleManager.viewer.scene.globe.show,
|
||||
hasLayer: Boolean(controller._imageryLayer),
|
||||
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||
};
|
||||
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
@@ -370,7 +374,7 @@ try {
|
||||
lastError: controller.getState().lastError,
|
||||
creditVisible: document.body.innerText.includes('Powered by Esri'),
|
||||
globeShown: styleManager.viewer.scene.globe.show,
|
||||
hasLayer: Boolean(controller._imageryLayer),
|
||||
hasLayer: Boolean(liveProvider() ?? controller._imageryLayer),
|
||||
active: [...document.querySelectorAll('.map-stack-chip')]
|
||||
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
|
||||
.map((chip) => chip.dataset.stackId),
|
||||
@@ -564,9 +568,16 @@ try {
|
||||
await page.keyboard.press('Enter');
|
||||
await waitTray('true', keyboardSource);
|
||||
|
||||
// Visual Styles precede the five Map Source tiles. Keep the existing opening
|
||||
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
|
||||
await pressTabs(5, true);
|
||||
// Visual Styles precede the Map Source section: its globe-engine picker
|
||||
// and tiles (OSM is the last). Keep the existing opening handoff to
|
||||
// selected OSM, then use the user's real Shift+Tab path into styles: one
|
||||
// press per control of that section.
|
||||
const mapSourceControls = await page.evaluate(
|
||||
() =>
|
||||
document.querySelectorAll('.map-stack-chip').length +
|
||||
(document.getElementById('globe-engine') ? 1 : 0),
|
||||
);
|
||||
await pressTabs(mapSourceControls, true);
|
||||
const noirFocus = await styleFocusMetrics();
|
||||
check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected',
|
||||
hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected
|
||||
|
||||
@@ -152,14 +152,15 @@ try {
|
||||
await settle();
|
||||
const before = await stats();
|
||||
const overlaps = await page.evaluate(async () => {
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const layer = window.__godsEyeView.dataManager.layers.get(
|
||||
'military-installations',
|
||||
).module;
|
||||
const rects = [];
|
||||
layer.visitNamedMarkers((id) => {
|
||||
const rect = getOverlayPaintRect('military-installations', id);
|
||||
const rect = getPaintRect('military-installations', id);
|
||||
if (rect) rects.push(rect);
|
||||
});
|
||||
let overlaps = 0;
|
||||
@@ -190,7 +191,7 @@ try {
|
||||
// Use the governor's hold so an asynchronous idle transition cannot
|
||||
// overwrite a raw scene flag during the sustained-render measurement.
|
||||
const { holdContinuousRender } = await import('/src/renderGovernor.js');
|
||||
holdContinuousRender('qa-military-names-fps');
|
||||
holdContinuousRender('qa-military-names-fps', window.__godsEyeView.engine);
|
||||
});
|
||||
const t0 = await pageNow(page);
|
||||
await sleep(3500);
|
||||
@@ -198,7 +199,7 @@ try {
|
||||
await page.evaluate(async () => {
|
||||
const { releaseContinuousRender } =
|
||||
await import('/src/renderGovernor.js');
|
||||
releaseContinuousRender('qa-military-names-fps');
|
||||
releaseContinuousRender('qa-military-names-fps', window.__godsEyeView.engine);
|
||||
});
|
||||
const metrics = frameStats(frames);
|
||||
const seconds = frames.reduce((sum, frame) => sum + frame.dt, 0) / 1000;
|
||||
@@ -214,7 +215,7 @@ try {
|
||||
);
|
||||
await sleep(300);
|
||||
result.cleanUi = await page.evaluate(async () => {
|
||||
const host = await import('/src/overlays/worldOverlay.js');
|
||||
const host = (await import('/src/overlays/worldOverlay.js')).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const root = document.getElementById('world-overlay-root');
|
||||
return {
|
||||
active: document.body.classList.contains('ui-clean-view'),
|
||||
@@ -223,9 +224,7 @@ try {
|
||||
opacityProperty: true,
|
||||
}),
|
||||
labels:
|
||||
host.getWorldOverlayDiagnostics().paintedBySource[
|
||||
'military-installations'
|
||||
] || 0,
|
||||
host.getDiagnostics().paintedBySource['military-installations'] || 0,
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
@@ -244,10 +243,10 @@ try {
|
||||
),
|
||||
);
|
||||
result.disabled = await page.evaluate(async () => {
|
||||
const host = await import('/src/overlays/worldOverlay.js');
|
||||
return host.getWorldOverlayDiagnostics().entriesBySource[
|
||||
'military-installations'
|
||||
];
|
||||
const host = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
return host.getDiagnostics().entriesBySource['military-installations'];
|
||||
});
|
||||
assert.equal(result.disabled, 0);
|
||||
await page.evaluate(() =>
|
||||
@@ -266,8 +265,9 @@ try {
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const C = await import('/node_modules/cesium/Build/Cesium/index.js');
|
||||
const { getOverlayPaintRect } =
|
||||
await import('/src/overlays/worldOverlay.js');
|
||||
const { getPaintRect } = (
|
||||
await import('/src/overlays/worldOverlay.js')
|
||||
).worldOverlayFor(window.__godsEyeView.engine);
|
||||
const g = window.__godsEyeView;
|
||||
const layer = g.dataManager.layers.get('military-installations').module;
|
||||
const overlay = document.createElement('div');
|
||||
@@ -284,7 +284,7 @@ try {
|
||||
labels = [],
|
||||
rects = [];
|
||||
layer.visitNamedMarkers((id, point) => {
|
||||
const rect = getOverlayPaintRect('military-installations', id);
|
||||
const rect = getPaintRect('military-installations', id);
|
||||
if (rect) rects.push(rect);
|
||||
const p = C.SceneTransforms.worldToWindowCoordinates(
|
||||
g.viewer.scene,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env node
|
||||
// Annotations on both engines, under the strict network policy: the same
|
||||
// board (a hand-drawn area, a pin in view and a pin on the far side of the
|
||||
// globe) is drawn on Navara, survives a switch to Cesium and back, and on
|
||||
// each engine the draped area paints amber pixels on the canvas, the near
|
||||
// pin's callout shows and the far pin is culled at the horizon.
|
||||
//
|
||||
// node scripts/qa-navara-annotations.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const LAT = 37.7749;
|
||||
const LON = -122.4194;
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${LAT}&lon=${LON}&alt=7000&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['annotations'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-annotations';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// The area: a box around the view centre, clear of the panels.
|
||||
const RING = [
|
||||
[LON - 0.012, LAT - 0.009],
|
||||
[LON + 0.012, LAT - 0.009],
|
||||
[LON + 0.012, LAT + 0.009],
|
||||
[LON - 0.012, LAT + 0.009],
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
|
||||
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||
const canvasAmber = async () => {
|
||||
await page.addStyleTag({
|
||||
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||
});
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||
await wait(700);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 340, y: 180, width: 600, height: 500 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.querySelectorAll('style')
|
||||
.forEach(
|
||||
(style) =>
|
||||
style.textContent.includes('gev-screen-whiteboard{visibility') &&
|
||||
style.remove(),
|
||||
),
|
||||
);
|
||||
let amber = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||
amber++;
|
||||
}
|
||||
return amber;
|
||||
};
|
||||
/** The callout texts the page board shows, and those it hides. */
|
||||
const board = () =>
|
||||
page.evaluate(() => {
|
||||
const out = { shown: [], hidden: [] };
|
||||
for (const group of document.querySelectorAll(
|
||||
'.gev-screen-whiteboard-svg g.gev-anno',
|
||||
)) {
|
||||
const text = group.querySelector('.gev-anno-text')?.textContent;
|
||||
if (!text) continue;
|
||||
const hidden =
|
||||
group.style.display === 'none' ||
|
||||
getComputedStyle(group).display === 'none';
|
||||
(hidden ? out.hidden : out.shown).push(text);
|
||||
}
|
||||
out.shown.sort();
|
||||
out.hidden.sort();
|
||||
return out;
|
||||
});
|
||||
|
||||
let round = 0;
|
||||
const drawn = await page.evaluate(
|
||||
async (ring, lat, lon) => {
|
||||
const annotations = window.__gevAnnotations;
|
||||
window.qaBoard = annotations;
|
||||
const result = await annotations.annotate(
|
||||
[
|
||||
{
|
||||
type: 'area',
|
||||
manual: true,
|
||||
ring,
|
||||
label: 'QA Area',
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: lat + 0.004,
|
||||
longitude: lon + 0.006,
|
||||
label: 'QA Near',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
type: 'pin',
|
||||
manual: true,
|
||||
latitude: -lat,
|
||||
longitude: lon + 180,
|
||||
label: 'QA Far',
|
||||
color: 'cyan',
|
||||
},
|
||||
],
|
||||
{ persist: true, flyTo: false },
|
||||
);
|
||||
return {
|
||||
drawn: result.drawn,
|
||||
attached: annotations.attached,
|
||||
handle: window.__godsEyeView.annotations === annotations,
|
||||
};
|
||||
},
|
||||
RING,
|
||||
LAT,
|
||||
LON,
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the board draws three marks on the attached engine and is the debug handle',
|
||||
drawn.drawn === 3 && drawn.attached && drawn.handle,
|
||||
drawn,
|
||||
);
|
||||
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.switchTo(page, kind);
|
||||
}
|
||||
await wait(2500);
|
||||
const state = await page.evaluate(() => ({
|
||||
same: window.__gevAnnotations === window.qaBoard,
|
||||
count: window.qaBoard.count(),
|
||||
attached: window.qaBoard.attached,
|
||||
handle: window.__godsEyeView.annotations === window.qaBoard,
|
||||
}));
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the same session board keeps its three marks and draws on this engine',
|
||||
state.same && state.count === 3 && state.attached && state.handle,
|
||||
state,
|
||||
);
|
||||
const seen = await board();
|
||||
console.log(kind, 'board', JSON.stringify(seen));
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the near callouts show and the far pin is culled at the horizon',
|
||||
seen.shown.includes('QA Near') &&
|
||||
seen.shown.includes('QA Area') &&
|
||||
!seen.shown.includes('QA Far'),
|
||||
seen,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/annotations-${kind}-${round}.png`,
|
||||
});
|
||||
const amber = await canvasAmber();
|
||||
// The same view without the marks (hidden from the list).
|
||||
const setHidden = (hidden) =>
|
||||
page.evaluate((h) => {
|
||||
for (const mark of window.qaBoard.list())
|
||||
window.qaBoard.setHidden(mark.id, h);
|
||||
}, hidden);
|
||||
await setHidden(true);
|
||||
const bare = await canvasAmber();
|
||||
await setHidden(false);
|
||||
console.log(kind, 'amber pixels', { bare, amber });
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'the draped area paints amber on the globe canvas',
|
||||
amber - bare > 400,
|
||||
{ bare, amber },
|
||||
);
|
||||
round++;
|
||||
}
|
||||
|
||||
// Fly-to a mark goes through the engine's camera on Navara.
|
||||
const flight = await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const before = engine.camera.height();
|
||||
const area = window.qaBoard.list().find((m) => m.label === 'QA Area');
|
||||
const ok = window.qaBoard.frame(area.id);
|
||||
await new Promise((r) => setTimeout(r, 2600));
|
||||
return { ok, before, after: engine.camera.height() };
|
||||
});
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'framing a mark flies the Navara camera to it',
|
||||
flight.ok && Math.abs(flight.after - flight.before) > 100,
|
||||
flight,
|
||||
);
|
||||
// Hidden from the list: the board and the canvas let it go.
|
||||
const hidden = await page.evaluate(async () => {
|
||||
for (const mark of window.qaBoard.list())
|
||||
window.qaBoard.setHidden(mark.id, true);
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return window.qaBoard.list().every((m) => m.hidden);
|
||||
});
|
||||
const afterHide = await board();
|
||||
await strict.check(
|
||||
page,
|
||||
'annotations',
|
||||
'hidden marks leave the board',
|
||||
hidden && afterHide.shown.length === 0,
|
||||
afterHide,
|
||||
);
|
||||
await page.evaluate(() => window.qaBoard.clear());
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS annotations drawn, culled and kept across engine switches',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
// The app's Bloom on Navara: the Display bloom glows the
|
||||
// Navara frame (brighter, more at a higher slider), it sits after the
|
||||
// overlays and before the visual styles, Cesium's dead zone draws nothing,
|
||||
// turning it off restores the frame exactly, and on Cesium the scene's own
|
||||
// bloom stage still runs with the engine left alone. Keyless preview under
|
||||
// the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-bloom.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
pickStyle,
|
||||
pinDaytime,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
const luma = (s) => 0.299 * s.r + 0.587 * s.g + 0.114 * s.b;
|
||||
|
||||
// The Display bloom, as its toggle and slider set it.
|
||||
const setBloom = (page, enabled, intensityPct) =>
|
||||
page.evaluate(
|
||||
(enabled, intensityPct) => {
|
||||
const a = window.__godsEyeView;
|
||||
a.styleManager.setBloom({ enabled, intensityPct });
|
||||
return {
|
||||
control: a.styleManager.getControlState().bloom,
|
||||
engine: a.engine.render.bloom?.() ?? null,
|
||||
pipeline: a.engine.pipeline?.() ?? null,
|
||||
};
|
||||
},
|
||||
enabled,
|
||||
intensityPct,
|
||||
);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
// The same sun on every run: the frame's brightness decides what blooms
|
||||
// and what each style's signature reads.
|
||||
await pinDaytime(page);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
return { bloom: engine.render.bloom(), pipeline: engine.pipeline() };
|
||||
});
|
||||
check(
|
||||
'Navara blooms itself and starts with bloom off',
|
||||
initial.bloom.enabled === false && !initial.pipeline?.bloomShown,
|
||||
initial,
|
||||
);
|
||||
const normal = await capture(page);
|
||||
save('normal', normal);
|
||||
const normalStats = stats(normal);
|
||||
|
||||
const full = await setBloom(page, true, 200);
|
||||
check(
|
||||
'the slider at 200 reaches the engine at full strength',
|
||||
full.engine.enabled === true &&
|
||||
full.engine.strength === 1 &&
|
||||
full.pipeline.bloomShown === true,
|
||||
full,
|
||||
);
|
||||
const order = full.pipeline.passes ?? [];
|
||||
const at = order.indexOf('gevBloom');
|
||||
check(
|
||||
'bloom runs after the scene and overlays, just before the final copy',
|
||||
at >= 0 &&
|
||||
order[at + 1] === 'final' &&
|
||||
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||
),
|
||||
order,
|
||||
);
|
||||
const glowFull = await capture(page);
|
||||
save('bloom-200', glowFull);
|
||||
const fullChange = diff(normal, glowFull);
|
||||
const fullStats = stats(glowFull);
|
||||
check(
|
||||
'bloom at 200 glows the frame brighter',
|
||||
fullChange.mean > 3 && luma(fullStats) > luma(normalStats) + 2,
|
||||
{
|
||||
...fullChange,
|
||||
normalLuma: luma(normalStats),
|
||||
bloomLuma: luma(fullStats),
|
||||
},
|
||||
);
|
||||
|
||||
await setBloom(page, true, 100);
|
||||
const glowHalf = await capture(page);
|
||||
save('bloom-100', glowHalf);
|
||||
const halfChange = diff(normal, glowHalf);
|
||||
check(
|
||||
'a lower slider glows less',
|
||||
halfChange.mean > 0.5 && halfChange.mean < fullChange.mean,
|
||||
{ half: halfChange.mean, full: fullChange.mean },
|
||||
);
|
||||
|
||||
const dead = await setBloom(page, true, 10);
|
||||
const deadFrame = await capture(page);
|
||||
check(
|
||||
"Cesium's dead zone: the toggle on, nothing glows",
|
||||
dead.engine.enabled === false &&
|
||||
dead.pipeline.bloomShown === false &&
|
||||
diff(normal, deadFrame).mean === 0,
|
||||
{ engine: dead.engine, diff: diff(normal, deadFrame) },
|
||||
);
|
||||
|
||||
// With a style: bloom first, then the style, then the final copy.
|
||||
await setBloom(page, true, 200);
|
||||
const styled = await pickStyle(page, 'noir', 'normal');
|
||||
const styledOrder = styled.pipeline?.passes ?? [];
|
||||
check(
|
||||
'bloom runs before the visual styles',
|
||||
styledOrder.indexOf('gevBloom') >= 0 &&
|
||||
styledOrder.indexOf('gevBloom') + 1 ===
|
||||
styledOrder.indexOf('gevStyles') &&
|
||||
styledOrder.indexOf('gevStyles') + 1 === styledOrder.indexOf('final'),
|
||||
styledOrder,
|
||||
);
|
||||
await pickStyle(page, 'normal', 'noir');
|
||||
|
||||
const off = await setBloom(page, false, 200);
|
||||
const offFrame = await capture(page);
|
||||
check(
|
||||
'turning bloom off restores the frame',
|
||||
off.engine.enabled === false &&
|
||||
off.pipeline.bloomShown === false &&
|
||||
diff(normal, offFrame).mean === 0,
|
||||
{ engine: off.engine, diff: diff(normal, offFrame) },
|
||||
);
|
||||
|
||||
// Cesium drives its scene bloom stage through the render contract.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
await setBloom(page, false, 200);
|
||||
const cesiumNormal = await capture(page);
|
||||
const cesium = await setBloom(page, true, 200);
|
||||
const cesiumStage = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
const stage = a.viewer?.scene?.postProcessStages?.bloom;
|
||||
return {
|
||||
setBloom: typeof a.engine.render.setBloom,
|
||||
enabled: stage?.enabled ?? null,
|
||||
contrast: stage?.uniforms?.contrast ?? null,
|
||||
};
|
||||
});
|
||||
const cesiumGlow = await capture(page);
|
||||
save('cesium-bloom-200', cesiumGlow);
|
||||
check(
|
||||
"Cesium: the engine drives its scene's bloom stage",
|
||||
cesium.engine?.enabled === true &&
|
||||
cesiumStage.setBloom === 'function' &&
|
||||
cesiumStage.enabled !== false &&
|
||||
diff(cesiumNormal, cesiumGlow).mean > 1,
|
||||
{ stage: cesiumStage, diff: diff(cesiumNormal, cesiumGlow) },
|
||||
);
|
||||
await setBloom(page, false, 200);
|
||||
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||
check(
|
||||
'Cesium: bloom off restores its frame',
|
||||
cesiumBack.mean < 0.5,
|
||||
cesiumBack,
|
||||
);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'bloom.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// The cockpit weather clouds on Navara: in cockpit mode with
|
||||
// the cockpit weather on, Navara's own volumetric clouds take their cover
|
||||
// from the weather under the camera (clear: none; overcast: a heavy deck),
|
||||
// with no second canvas or WebGL context; leaving the cockpit gives the look
|
||||
// its own clouds back. On Cesium the cockpit keeps its own cloud canvas and
|
||||
// the engine draws none. The weather route is a fixture, the page clock is
|
||||
// shifted to an Austin midday (the look shows clouds only in daylight), and
|
||||
// cockpit mode is entered through its event (the cloud controllers listen
|
||||
// to it; a real cockpit needs a tracked aircraft). Keyless preview under the
|
||||
// strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-cockpit-clouds.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const LOW_VIEW =
|
||||
'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=90&pitch=-8&map=osm&l=';
|
||||
const MIDDAY_UTC = '2026-07-15T18:00:00Z';
|
||||
const WEATHER = {
|
||||
clear: { weatherCode: 0, cloudCoverPct: 0, windKph: 6, visibilityM: 20000 },
|
||||
overcast: {
|
||||
weatherCode: 3,
|
||||
cloudCoverPct: 100,
|
||||
windKph: 25,
|
||||
visibilityM: 15000,
|
||||
},
|
||||
};
|
||||
let weather = WEATHER.clear;
|
||||
const asked = [];
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: [],
|
||||
routes: [
|
||||
{
|
||||
pattern: `${base.origin}/api/weather-effects*`,
|
||||
respond: (request) => {
|
||||
asked.push(request.url);
|
||||
return {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'live', weather }),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
|
||||
// Clouds grey the frame: the blue sky and the map's coloured lines go.
|
||||
const saturation = (pixels) => stats(pixels).saturation;
|
||||
|
||||
// A few seconds of continuous frames: the clouds converge over frames.
|
||||
async function converged(page, name) {
|
||||
await page.evaluate(async () => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.render.setContinuous(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
engine.render.setContinuous(false);
|
||||
});
|
||||
const pixels = await capture(page);
|
||||
save(name, pixels);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
const cockpit = (page, active) =>
|
||||
page.evaluate((active) => {
|
||||
document.body.classList.toggle('cockpit-mode', active);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-mode-changed', { detail: { active } }),
|
||||
);
|
||||
}, active);
|
||||
const weatherToggle = (page, enabled) =>
|
||||
page.evaluate((enabled) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-toggle', { detail: { enabled } }),
|
||||
);
|
||||
}, enabled);
|
||||
const coverage = (page) =>
|
||||
page.evaluate(
|
||||
() => window.__godsEyeView.engine.render.cloudCover?.().coverage,
|
||||
);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.evaluateOnNewDocument((utc) => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
const offset = Date.parse(utc) - Date.now();
|
||||
const RealDate = Date;
|
||||
class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
}
|
||||
globalThis.Date = ShiftedDate;
|
||||
}, MIDDAY_UTC);
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara', '', LOW_VIEW), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const setup = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
return {
|
||||
cover: a.engine.render.cloudCover?.() ?? null,
|
||||
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||
canvases: document.querySelectorAll('canvas').length,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Navara takes the cockpit cloud cover itself, no cloud canvas',
|
||||
setup.cover?.coverage === null && !setup.canvas,
|
||||
setup,
|
||||
);
|
||||
const lookOwn = await converged(page, 'look-own');
|
||||
|
||||
await weatherToggle(page, true);
|
||||
await cockpit(page, true);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.cloudCover().coverage === 0,
|
||||
{ timeout: 10000, polling: 100 },
|
||||
);
|
||||
check('clear weather in the cockpit: no cloud cover', asked.length >= 1, {
|
||||
requests: asked.length,
|
||||
});
|
||||
const clear = await converged(page, 'cockpit-clear');
|
||||
|
||||
// Overcast 30 km east: the movement makes the weather stale.
|
||||
weather = WEATHER.overcast;
|
||||
await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const place = engine.camera.pose().cartographic;
|
||||
const east = place.longitude + 30000 / (6378137 * Math.cos(place.latitude));
|
||||
const { sin, cos } = Math;
|
||||
// WGS84 Earth-fixed position of (east, latitude, height).
|
||||
const a = 6378137;
|
||||
const e2 = 0.00669437999014;
|
||||
const n = a / Math.sqrt(1 - e2 * sin(place.latitude) ** 2);
|
||||
const h = place.height;
|
||||
engine.camera.setView({
|
||||
destination: {
|
||||
x: (n + h) * cos(place.latitude) * cos(east),
|
||||
y: (n + h) * cos(place.latitude) * sin(east),
|
||||
z: (n * (1 - e2) + h) * sin(place.latitude),
|
||||
},
|
||||
orientation: { heading: Math.PI / 2, pitch: -0.14, roll: 0 },
|
||||
});
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.cloudCover().coverage > 0.4,
|
||||
{ timeout: 15000, polling: 100 },
|
||||
);
|
||||
const overcastCover = await coverage(page);
|
||||
const overcast = await converged(page, 'cockpit-overcast');
|
||||
const greyed = {
|
||||
lookOwn: saturation(lookOwn),
|
||||
clear: saturation(clear),
|
||||
overcast: saturation(overcast),
|
||||
clearToOvercast: diff(clear, overcast).mean,
|
||||
};
|
||||
check(
|
||||
'overcast weather covers the view with Navara clouds, clear leaves it open',
|
||||
overcastCover > 0.4 &&
|
||||
greyed.overcast < greyed.clear * 0.6 &&
|
||||
greyed.lookOwn < greyed.clear &&
|
||||
greyed.clearToOvercast > 15,
|
||||
{ overcastCover, greyed, requests: asked.length },
|
||||
);
|
||||
const contexts = await page.evaluate(() => ({
|
||||
canvas: Boolean(document.getElementById('cockpit-cloud-effects')),
|
||||
canvases: document.querySelectorAll('canvas').length,
|
||||
}));
|
||||
check(
|
||||
'no second canvas or WebGL context in the cockpit',
|
||||
!contexts.canvas && contexts.canvases === setup.canvases,
|
||||
{ before: setup.canvases, after: contexts },
|
||||
);
|
||||
|
||||
await cockpit(page, false);
|
||||
check(
|
||||
'leaving the cockpit gives the look its own clouds back',
|
||||
(await coverage(page)) === null,
|
||||
);
|
||||
|
||||
// Cesium keeps its own cockpit cloud canvas; its engine draws none.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
weather = WEATHER.overcast;
|
||||
await cockpit(page, true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Number(
|
||||
document.getElementById('cockpit-cloud-effects')?.dataset.cloudStrength,
|
||||
) > 0.3,
|
||||
{ timeout: 15000, polling: 100 },
|
||||
);
|
||||
const cesium = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('cockpit-cloud-effects');
|
||||
return {
|
||||
setCloudCover: typeof window.__godsEyeView.engine.render.setCloudCover,
|
||||
strength: Number(canvas.dataset.cloudStrength),
|
||||
cockpit: canvas.dataset.cockpit,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Cesium: the cockpit draws its own cloud canvas, the engine none',
|
||||
cesium.setCloudCover === 'undefined' && cesium.cockpit === 'true',
|
||||
cesium,
|
||||
);
|
||||
await cockpit(page, false);
|
||||
await weatherToggle(page, false);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'cockpit-clouds.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env node
|
||||
// UTILITIES ▸ Draw on Navara under the strict network policy, with real
|
||||
// clicks and keys: Draw takes the pointer, clicks on the globe are vertices
|
||||
// with a live rubber band, a double click finishes an area that paints on
|
||||
// the canvas and joins the session board, Enter finishes a line, Escape
|
||||
// cancels a sketch and then leaves Draw, and a switch to Cesium in the
|
||||
// middle of a sketch ends it (the pointer is free, shape and colour kept);
|
||||
// the shapes drawn on either engine stay on the board and draw on both.
|
||||
//
|
||||
// node scripts/qa-navara-draw.mjs http://127.0.0.1:<PORT>/
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const VIEW = { lat: 37.7749, lon: -122.4194, alt: 6000 };
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['draw'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-draw';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// An area's corners, clear of the open UTILITIES panel and the rails.
|
||||
const AREA = [
|
||||
[560, 330],
|
||||
[760, 330],
|
||||
[760, 500],
|
||||
[560, 500],
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
const ready = async (kind) => {
|
||||
await page.waitForFunction(
|
||||
(k) =>
|
||||
window.__godsEyeView?.engine?.kind === k &&
|
||||
window.__gevDrawTool?.attached &&
|
||||
!document.querySelector(
|
||||
'#globe-engine:is([disabled], [aria-busy="true"])',
|
||||
) &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout: 120000 },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
await assertKeylessPreview(page);
|
||||
const tool = () =>
|
||||
page.evaluate(() => {
|
||||
const draw = window.__gevDrawTool;
|
||||
return {
|
||||
active: draw.active,
|
||||
shape: draw.shape,
|
||||
color: draw.color,
|
||||
vertices: draw.session?.vertices.length ?? null,
|
||||
board: window.__gevAnnotations.count(),
|
||||
attached: window.__gevAnnotations.attached,
|
||||
diagnostics: draw.diagnostics(),
|
||||
band:
|
||||
window.__godsEyeView.engine.lines
|
||||
?.list?.()
|
||||
.find(({ id }) => id === 'gev-draw-preview') ?? null,
|
||||
};
|
||||
});
|
||||
/** Amber pixels of the globe canvas alone (the SVG board hidden). */
|
||||
const amber = async () => {
|
||||
await page.addStyleTag({
|
||||
content: '.gev-screen-whiteboard{visibility:hidden!important}',
|
||||
});
|
||||
await wait(800);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 520, y: 300, width: 280, height: 230 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
await page.evaluate(() =>
|
||||
document.querySelectorAll('style').forEach((style) => {
|
||||
if (style.textContent.includes('gev-screen-whiteboard{visibility'))
|
||||
style.remove();
|
||||
}),
|
||||
);
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (r > 150 && g > 90 && g < 225 && b < 150 && r - b > 60 && r - g > 15)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const openUtilities = () =>
|
||||
page.evaluate(() => {
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const drawShape = async (shape, points, finish) => {
|
||||
await page.evaluate((s) => {
|
||||
window.__gevDrawTool.setShape(s);
|
||||
window.__gevDrawTool.setColor('amber');
|
||||
}, shape);
|
||||
for (const [x, y] of points) {
|
||||
await page.mouse.click(x, y);
|
||||
await wait(120);
|
||||
}
|
||||
await finish();
|
||||
await wait(800);
|
||||
};
|
||||
|
||||
await openUtilities();
|
||||
await page.waitForSelector('#draw-toggle', { visible: true });
|
||||
const bare = await amber();
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
const on = await page.evaluate(() => ({
|
||||
owner: document.body.classList.contains('gev-drawing'),
|
||||
}));
|
||||
// Three corners, then the rubber band to the fourth under the pointer.
|
||||
await page.evaluate(() => {
|
||||
window.__gevDrawTool.setShape('area');
|
||||
window.__gevDrawTool.setColor('amber');
|
||||
});
|
||||
// Each click is a vertex (the rubber band under the pointer never
|
||||
// swallows one).
|
||||
for (const [index, [x, y]] of AREA.slice(0, 3).entries()) {
|
||||
await page.mouse.click(x, y);
|
||||
await page.waitForFunction(
|
||||
(n) => window.__gevDrawTool.session?.vertices.length === n,
|
||||
{ timeout: 3000 },
|
||||
index + 1,
|
||||
);
|
||||
await wait(150);
|
||||
}
|
||||
await page.mouse.move(...AREA[3]);
|
||||
await wait(300);
|
||||
const sketch = await tool();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-sketch.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Draw takes the pointer; clicks on the globe are vertices with a live rubber band',
|
||||
on.owner &&
|
||||
sketch.active &&
|
||||
sketch.vertices === 3 &&
|
||||
sketch.diagnostics.sceneHandler &&
|
||||
sketch.band?.shown === true,
|
||||
sketch,
|
||||
);
|
||||
await page.mouse.click(AREA[3][0], AREA[3][1], { count: 2 });
|
||||
await page.waitForFunction(() => window.__gevAnnotations.count() === 1, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await wait(800);
|
||||
const drawn = await amber();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-area.png` });
|
||||
const area = await page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((m) => [m.type, m.color, m.manual]),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a double click finishes the area: it joins the board and paints amber on Navara',
|
||||
area.length === 1 && area[0][0] === 'area' && drawn - bare > 1500,
|
||||
{ area, bare, drawn },
|
||||
);
|
||||
// Enter finishes a line.
|
||||
await drawShape(
|
||||
'line',
|
||||
[
|
||||
[600, 560],
|
||||
[760, 600],
|
||||
],
|
||||
() => page.keyboard.press('Enter'),
|
||||
);
|
||||
const lines = await page.evaluate(() =>
|
||||
window.__gevAnnotations.list().map((m) => m.type),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Enter on the map finishes a line',
|
||||
lines.length === 2 && lines.includes('route'),
|
||||
{ lines },
|
||||
);
|
||||
// Escape: the sketch, then Draw itself.
|
||||
await page.evaluate(() => window.__gevDrawTool.setShape('line'));
|
||||
await page.mouse.click(620, 360);
|
||||
await page.mouse.click(700, 380);
|
||||
const before = await tool();
|
||||
await page.keyboard.press('Escape');
|
||||
const cancelled = await tool();
|
||||
await page.keyboard.press('Escape');
|
||||
const left = await tool();
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'Escape cancels the sketch, then leaves Draw and gives the pointer back',
|
||||
before.vertices === 2 &&
|
||||
cancelled.active &&
|
||||
cancelled.vertices === 0 &&
|
||||
!left.active &&
|
||||
!left.diagnostics.sceneHandler &&
|
||||
left.diagnostics.pointerOwner == null &&
|
||||
left.band?.shown !== true,
|
||||
{ before, cancelled, left },
|
||||
);
|
||||
assert.equal(left.board, 2, 'Escape keeps the finished shapes');
|
||||
|
||||
// A switch to Cesium in the middle of a sketch.
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
await page.evaluate(() => {
|
||||
window.__gevDrawTool.setShape('line');
|
||||
window.__gevDrawTool.setColor('green');
|
||||
});
|
||||
await page.mouse.click(620, 360);
|
||||
await page.mouse.click(700, 380);
|
||||
assert.equal((await tool()).vertices, 2);
|
||||
await strict.pick(page, 'cesium');
|
||||
await ready('cesium');
|
||||
await wait(1500);
|
||||
const switched = await tool();
|
||||
await page.screenshot({ path: `${SHOTS}/cesium-after-switch.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a switch mid-sketch ends the sketch, frees the pointer and keeps shape, colour and the board',
|
||||
!switched.active &&
|
||||
switched.vertices === null &&
|
||||
switched.diagnostics.pointerOwner == null &&
|
||||
switched.shape === 'line' &&
|
||||
switched.color === 'green' &&
|
||||
switched.board === 2 &&
|
||||
switched.attached,
|
||||
switched,
|
||||
);
|
||||
// Draw on Cesium too, with the same clicks.
|
||||
await openUtilities();
|
||||
await page.click('#draw-toggle');
|
||||
await page.waitForFunction(() => window.__gevDrawTool.active);
|
||||
await drawShape(
|
||||
'line',
|
||||
[
|
||||
[600, 420],
|
||||
[760, 450],
|
||||
],
|
||||
() => page.keyboard.press('Enter'),
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
const onCesium = await tool();
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'a shape drawn on Cesium joins the same board',
|
||||
onCesium.board === 3 && !onCesium.active,
|
||||
onCesium,
|
||||
);
|
||||
await strict.pick(page, 'navara');
|
||||
await ready('navara');
|
||||
await wait(2000);
|
||||
const back = await tool();
|
||||
const backAmber = await amber();
|
||||
await page.screenshot({ path: `${SHOTS}/navara-back.png` });
|
||||
await strict.check(
|
||||
page,
|
||||
'draw',
|
||||
'back on Navara the three shapes are on the board and the area paints again',
|
||||
back.board === 3 && back.attached && backAmber - bare > 1500,
|
||||
{ board: back.board, bare, backAmber },
|
||||
);
|
||||
await page.evaluate(() => window.__gevAnnotations.clear());
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS Draw on Navara with real clicks and keys');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
// UTILITIES ▸ Import on Navara under the strict network policy: GeoJSON,
|
||||
// KML, KMZ (a packed icon and a ground overlay) and GPX through the real
|
||||
// file input, each one row; Navara draws their shapes in the row colour and
|
||||
// their pins and packed ground overlay; unsupported content is counted
|
||||
// in the hint; hide, fly-to and remove work on the engine; the files
|
||||
// survive a switch to Cesium and back and draw on each engine. No file makes
|
||||
// the page request anything it names (the strict policy fails any request
|
||||
// outside the preview and the free tile cache).
|
||||
//
|
||||
// node scripts/qa-navara-geo-import.mjs http://127.0.0.1:<PORT>/
|
||||
import { assertKeylessPreview } from './qa-strict-harness.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import { createStrict, localPreview } from './qa-strict-harness.mjs';
|
||||
|
||||
const VIEW = { lat: 37.7694, lon: -122.4862, alt: 4200 };
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash = `v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&roll=0&map=osm&l=`;
|
||||
const strict = createStrict({ origin: base.origin, layers: ['geo-import'] });
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-geo-import';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const TRAP = 'gev-qa-trap.invalid';
|
||||
|
||||
/* ── fixtures (as scripts/qa-geo-import.mjs) ───────────────────────────── */
|
||||
const ring = [
|
||||
[-122.511, 37.7645],
|
||||
[-122.4545, 37.7645],
|
||||
[-122.4545, 37.7745],
|
||||
[-122.511, 37.7745],
|
||||
[-122.511, 37.7645],
|
||||
];
|
||||
const geojson = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: 'QA park',
|
||||
description: `<img src="https://${TRAP}/props.png">`,
|
||||
},
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { name: 'QA pin' },
|
||||
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const kml = (iconHref, overlay) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
|
||||
<NetworkLink><Link><href>https://${TRAP}/link.kml</href></Link></NetworkLink>
|
||||
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
|
||||
<description><![CDATA[<img src="https://${TRAP}/balloon.png">]]></description>
|
||||
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
|
||||
<Placemark><name>QA KML line</name>
|
||||
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
||||
<GroundOverlay><Icon><href>${overlay}</href></Icon>
|
||||
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
|
||||
</Document>
|
||||
</kml>`;
|
||||
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
|
||||
<trk><name>QA track</name><trkseg>
|
||||
<trkpt lat="37.766" lon="-122.505"></trkpt><trkpt lat="37.768" lon="-122.49"></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
// 8×8 opaque white PNG.
|
||||
const PNG = await sharp({
|
||||
create: { width: 8, height: 8, channels: 4, background: '#ffffff' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
function storedZip(files) {
|
||||
const parts = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const [name, data] of files) {
|
||||
const n = Buffer.from(name);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(n.length, 26);
|
||||
const c = Buffer.alloc(46);
|
||||
c.writeUInt32LE(0x02014b50, 0);
|
||||
c.writeUInt32LE(data.length, 20);
|
||||
c.writeUInt32LE(data.length, 24);
|
||||
c.writeUInt16LE(n.length, 28);
|
||||
c.writeUInt32LE(offset, 42);
|
||||
parts.push(local, n, data);
|
||||
central.push(c, n);
|
||||
offset += 30 + n.length + data.length;
|
||||
}
|
||||
const dir = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(files.length, 8);
|
||||
end.writeUInt16LE(files.length, 10);
|
||||
end.writeUInt32LE(dir.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...parts, dir, end]);
|
||||
}
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-navara-import-'));
|
||||
const fixture = (name, data) => {
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, data);
|
||||
return file;
|
||||
};
|
||||
const FILES = [
|
||||
fixture('park.geojson', geojson),
|
||||
fixture(
|
||||
'remote-bits.kml',
|
||||
kml(`https://${TRAP}/icon.png`, `https://${TRAP}/overlay.png`),
|
||||
),
|
||||
fixture(
|
||||
'packed.kmz',
|
||||
storedZip([
|
||||
['doc.kml', Buffer.from(kml('files/pin.png', 'files/overlay.png'))],
|
||||
['files/pin.png', PNG],
|
||||
['files/overlay.png', PNG],
|
||||
]),
|
||||
),
|
||||
fixture('walk.gpx', gpx),
|
||||
];
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
const ready = async (kind) => {
|
||||
await page.waitForFunction(
|
||||
(k) =>
|
||||
window.__godsEyeView?.engine?.kind === k &&
|
||||
window.__gevGeoImport?.diagnostics().attached &&
|
||||
!document.querySelector(
|
||||
'#globe-engine:is([disabled], [aria-busy="true"])',
|
||||
) &&
|
||||
!document.querySelector('.engine-switch-frame') &&
|
||||
document.querySelector('#loading-screen.hidden'),
|
||||
{ timeout: 120000 },
|
||||
kind,
|
||||
);
|
||||
await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
||||
);
|
||||
};
|
||||
await ready('navara');
|
||||
await assertKeylessPreview(page);
|
||||
const state = () =>
|
||||
page.evaluate(() => ({
|
||||
files: window.__gevGeoImport.list(),
|
||||
diagnostics: window.__gevGeoImport.diagnostics(),
|
||||
rows: document.querySelectorAll('#geo-import-list .geo-import-row')
|
||||
.length,
|
||||
hint: document.getElementById('geo-import-hint')?.textContent || '',
|
||||
// Both engines draw imported pins through mover sets.
|
||||
pins: window.__godsEyeView.engine.movers
|
||||
.list()
|
||||
.filter(({ id }) => id.startsWith('geo-import-'))
|
||||
.reduce((n, set) => n + set.count, 0),
|
||||
}));
|
||||
/** Pixels near the first file's colour (#39d0ff) on the canvas. */
|
||||
const cyan = async () => {
|
||||
await page.evaluate(() => window.__godsEyeView.requestRender?.('qa'));
|
||||
await wait(900);
|
||||
const { data, info } = await sharp(
|
||||
await page.screenshot({
|
||||
clip: { x: 400, y: 260, width: 480, height: 300 },
|
||||
}),
|
||||
)
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels) {
|
||||
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||
if (b > 120 && g > 90 && b - r > 50) n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const setAll = (visible) =>
|
||||
page.evaluate((v) => {
|
||||
for (const file of window.__gevGeoImport.list())
|
||||
window.__gevGeoImport.setVisible(file.id, v);
|
||||
}, visible);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const panel = document.getElementById('utilities-panel');
|
||||
if (panel?.classList.contains('collapsed'))
|
||||
document
|
||||
.querySelector(
|
||||
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const input = await page.$('#geo-import-input');
|
||||
assert.ok(input, 'the Import file input');
|
||||
await input.uploadFile(...FILES);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
window.__gevGeoImport.list().length === 4 &&
|
||||
!/Reading/.test(
|
||||
document.getElementById('geo-import-hint')?.textContent || '',
|
||||
),
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
const imported = await state();
|
||||
console.log('navara import', JSON.stringify(imported));
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'GeoJSON, KML, KMZ and GPX each become one row and one drawing on Navara',
|
||||
imported.files.length === 4 &&
|
||||
imported.rows === 4 &&
|
||||
imported.diagnostics.drawn === 4,
|
||||
{ files: imported.files.map((f) => f.format), rows: imported.rows },
|
||||
);
|
||||
// Packed overlays draw through imagery; remote overlays are removed on import.
|
||||
const undrawn = Object.fromEntries(
|
||||
imported.files.map((f) => [f.name, f.undrawn]),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'all supported geometry, including the packed overlay, draws on Navara',
|
||||
undrawn['packed.kmz'] === 0 &&
|
||||
undrawn['park.geojson'] === 0 &&
|
||||
undrawn['remote-bits.kml'] === 0 &&
|
||||
undrawn['walk.gpx'] === 0,
|
||||
{ undrawn, hint: imported.hint },
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the files’ pins are drawn as Navara sprites',
|
||||
imported.pins >= 4,
|
||||
{ pins: imported.pins },
|
||||
);
|
||||
// The first file is flown to after the batch; settle there.
|
||||
await wait(3000);
|
||||
await page.screenshot({ path: `${SHOTS}/navara-imported.png` });
|
||||
|
||||
for (const kind of ['navara', 'cesium', 'navara']) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.pick(page, kind);
|
||||
await ready(kind);
|
||||
await page.waitForFunction(
|
||||
() => window.__gevGeoImport.diagnostics().drawn === 4,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await wait(2500);
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-after-switch.png` });
|
||||
}
|
||||
const shown = await cyan();
|
||||
await setAll(false);
|
||||
const hidden = await cyan();
|
||||
await setAll(true);
|
||||
console.log(kind, 'cyan', { shown, hidden });
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the imported shapes paint the first file’s colour and stop when hidden',
|
||||
shown - hidden > 1500,
|
||||
{ shown, hidden },
|
||||
);
|
||||
const kept = await state();
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'the same four files are listed and drawn on this engine',
|
||||
kept.files.length === 4 && kept.diagnostics.drawn === 4,
|
||||
kept.diagnostics,
|
||||
);
|
||||
}
|
||||
// Fly-to on Navara goes through the engine camera.
|
||||
const flight = await page.evaluate(
|
||||
async (destination) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
engine.camera.setView({ destination });
|
||||
const before = engine.camera.height();
|
||||
const [first] = window.__gevGeoImport.list();
|
||||
const ok = window.__gevGeoImport.flyTo(first.id);
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
return { ok, before, after: engine.camera.height() };
|
||||
},
|
||||
fromDegrees(-100, 40, 3e6),
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'Fly to a file moves the Navara camera onto it',
|
||||
flight.ok && flight.after < flight.before / 2,
|
||||
flight,
|
||||
);
|
||||
// Remove gives the drawing back.
|
||||
await page.evaluate(() => {
|
||||
for (const file of window.__gevGeoImport.list())
|
||||
window.__gevGeoImport.remove(file.id);
|
||||
});
|
||||
const removed = await state();
|
||||
await strict.check(
|
||||
page,
|
||||
'geo-import',
|
||||
'Remove takes every file and its pins off Navara',
|
||||
removed.files.length === 0 && removed.rows === 0 && removed.pins === 0,
|
||||
{ files: removed.files.length, pins: removed.pins },
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS Navara import of GeoJSON, KML, KMZ and GPX');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
// The DISPLAY scope mask and the celestial ring on Navara, under the strict
|
||||
// network policy: at city altitude the mask canvas sits in the engine's
|
||||
// container with an opaque outside, the screen's edge goes dark with the
|
||||
// scope on and shows the map with it off, and the ring overlay draws; the
|
||||
// same holds after a switch to Cesium and back.
|
||||
//
|
||||
// node scripts/qa-navara-scope.mjs http://127.0.0.1:<PORT>/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import sharp from 'sharp';
|
||||
import { fromDegrees } from '../src/geo/cartesian3.js';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
base.search = '?engine=navara&welcome=0';
|
||||
base.hash =
|
||||
'v=2&lat=37.7749&lon=-122.4194&alt=3000&heading=0&pitch=-90&roll=0&map=osm&l=';
|
||||
const strict = createStrict({
|
||||
origin: base.origin,
|
||||
layers: ['scope-mask', 'celestial-ring'],
|
||||
});
|
||||
const SHOTS = process.env.QA_SHOT_DIR || 'qa-shots/navara-scope';
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// The screen's right edge below the rail: outside the keyhole, clear of UI.
|
||||
const GLOBE = fromDegrees(-122.4194, 37.7749, 20_000_000);
|
||||
const CITY = fromDegrees(-122.4194, 37.7749, 3000);
|
||||
const EDGE = { x: 1250, y: 560, width: 26, height: 90 };
|
||||
|
||||
await strict.run(
|
||||
{
|
||||
args: [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
],
|
||||
},
|
||||
async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 860 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(base.href);
|
||||
await waitForEngine(page, 'navara');
|
||||
const edgeLuminance = async () => {
|
||||
await wait(900);
|
||||
const { data, info } = await sharp(await page.screenshot({ clip: EDGE }))
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels)
|
||||
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
|
||||
return sum / (data.length / info.channels);
|
||||
};
|
||||
// DISPLAY ▸ Scope, as its checkbox sends it.
|
||||
const setScope = async (enabled) => {
|
||||
const found = await page.evaluate((on) => {
|
||||
const toggle = document.getElementById('scope-toggle');
|
||||
if (!toggle) return false;
|
||||
toggle.checked = on;
|
||||
toggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
}, enabled);
|
||||
assert.ok(found, 'the DISPLAY scope toggle');
|
||||
};
|
||||
|
||||
for (const [round, kind] of ['navara', 'cesium', 'navara'].entries()) {
|
||||
if (
|
||||
(await page.evaluate(() => window.__godsEyeView.engine.kind)) !== kind
|
||||
) {
|
||||
await strict.switchTo(page, kind);
|
||||
}
|
||||
await wait(2500);
|
||||
const mask = await page.evaluate(() => {
|
||||
const canvas = document.getElementById('scope-mask');
|
||||
const { engine } = window.__godsEyeView;
|
||||
const context = canvas?.getContext('2d');
|
||||
const corner = context?.getImageData(2, 2, 1, 1).data;
|
||||
const middle = context?.getImageData(
|
||||
Math.round(canvas.width / 2),
|
||||
Math.round(canvas.height / 2),
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
return {
|
||||
exists: Boolean(canvas),
|
||||
inContainer: canvas?.parentElement === engine.host.container,
|
||||
count: document.querySelectorAll('#scope-mask').length,
|
||||
height: Math.round(engine.camera.height()),
|
||||
cornerAlpha: corner?.[3],
|
||||
middleAlpha: middle?.[3],
|
||||
};
|
||||
});
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-scope.png` });
|
||||
const on = await edgeLuminance();
|
||||
await setScope(false);
|
||||
const off = await edgeLuminance();
|
||||
const cleared = await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getElementById('scope-mask')
|
||||
?.getContext('2d')
|
||||
.getImageData(2, 2, 1, 1).data[3],
|
||||
);
|
||||
await setScope(true);
|
||||
console.log(kind, JSON.stringify({ mask, on, off, cleared }));
|
||||
await strict.check(
|
||||
page,
|
||||
'scope-mask',
|
||||
'one scope canvas in the engine container, opaque outside and clear inside at city altitude',
|
||||
mask.exists &&
|
||||
mask.inContainer &&
|
||||
mask.count === 1 &&
|
||||
mask.cornerAlpha === 255 &&
|
||||
mask.middleAlpha === 0,
|
||||
mask,
|
||||
);
|
||||
await strict.check(
|
||||
page,
|
||||
'scope-mask',
|
||||
'the screen edge is dark with the scope on and shows the map with it off',
|
||||
on < 12 && off > on + 30 && cleared === 0,
|
||||
{ on, off, cleared },
|
||||
);
|
||||
// The ring is a full-globe view: up to the whole Earth, ring on.
|
||||
await page.evaluate((destination) => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
engine.camera.setView({ destination });
|
||||
styleManager.setCelestialRingEnabled(true);
|
||||
}, GLOBE);
|
||||
await wait(2500);
|
||||
const ring = await page.evaluate(() => {
|
||||
const overlay = document.getElementById('celestial-ring-overlay');
|
||||
const canvases = [...(overlay?.querySelectorAll('canvas') ?? [])];
|
||||
return {
|
||||
exists: Boolean(overlay),
|
||||
disabled: overlay?.classList.contains('disabled') ?? null,
|
||||
visible: overlay?.classList.contains('visible') ?? null,
|
||||
updates: Number(overlay?.dataset.ephemerisUpdates ?? 0),
|
||||
canvases: canvases.map((c) => [c.width, c.height]),
|
||||
};
|
||||
});
|
||||
await page.screenshot({ path: `${SHOTS}/${kind}-${round}-ring.png` });
|
||||
console.log(kind, 'ring', JSON.stringify(ring));
|
||||
await strict.check(
|
||||
page,
|
||||
'celestial-ring',
|
||||
'at full-globe altitude the celestial ring draws its sun and moon',
|
||||
ring.exists &&
|
||||
ring.disabled === false &&
|
||||
ring.visible === true &&
|
||||
ring.updates >= 1 &&
|
||||
ring.canvases.length === 2 &&
|
||||
ring.canvases.every(([w, h]) => w > 0 && h > 0),
|
||||
ring,
|
||||
);
|
||||
// Back down to the city for the next engine.
|
||||
await page.evaluate((destination) => {
|
||||
const { engine, styleManager } = window.__godsEyeView;
|
||||
styleManager.setCelestialRingEnabled(false);
|
||||
engine.camera.setView({ destination });
|
||||
}, CITY);
|
||||
await wait(1000);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS scope mask and celestial ring on both engines');
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,286 @@
|
||||
// The Cyber HUD sonar on Navara: with the Cyber HUD on, the
|
||||
// sweep lights the contacts it passes and dims the rest to the opacity
|
||||
// floor, by each contact's place on screen exactly as the CPU sampler of
|
||||
// src/cyberSonar.js rates it; the sweep turns; with the sweep off every
|
||||
// contact takes the neutral factor; a parked scene draws at the fleet
|
||||
// cadence only while the sweep runs over contacts; leaving the Cyber HUD
|
||||
// gives the contacts back unchanged. On Cesium the command adapter keeps the
|
||||
// treatment and the engine draws none. The contacts are an engine point set
|
||||
// (engine.movers) in a ring around the screen centre. Keyless preview under
|
||||
// the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-sonar.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import { checker, freeze, visualPageUrl } from './qa/navaraVisual.mjs';
|
||||
import { sonarFactorAt } from '../src/globe/navara/sonar.js';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
const WIDTH = 1280;
|
||||
const HEIGHT = 800;
|
||||
|
||||
// 32 contacts on a ring at half the keyhole radius (11.25° apart: one is
|
||||
// always well inside the 24° sector), and one outside the keyhole.
|
||||
function contactPlaces() {
|
||||
const radius = Math.min(WIDTH * 0.4, HEIGHT * 0.52) * 0.5;
|
||||
const places = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const angle = (i / 32) * Math.PI * 2;
|
||||
places.push({
|
||||
x: Math.round(WIDTH / 2 + Math.sin(angle) * radius),
|
||||
y: Math.round(HEIGHT / 2 - Math.cos(angle) * radius),
|
||||
});
|
||||
}
|
||||
places.push({ x: 120, y: 110 });
|
||||
return places;
|
||||
}
|
||||
|
||||
// The luma at each contact (a 3×3 mean) of the next drawn frame, and the
|
||||
// sonar state that frame drew with.
|
||||
const read = (page, places) =>
|
||||
page.evaluate(async (places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = document.createElement('canvas');
|
||||
const source = engine.host.canvas;
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
let sonar = null;
|
||||
await new Promise((resolve) => {
|
||||
const off = engine.frames.postRender.addEventListener(() => {
|
||||
off();
|
||||
ctx.drawImage(source, 0, 0);
|
||||
sonar = engine.render.contactSonar?.() ?? null;
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-sonar');
|
||||
});
|
||||
const scale = source.width / engine.host.size().width;
|
||||
const luma = places.map(({ x, y }) => {
|
||||
const data = ctx.getImageData(
|
||||
Math.round(x * scale) - 1,
|
||||
Math.round(y * scale) - 1,
|
||||
3,
|
||||
3,
|
||||
).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||
return sum / 9;
|
||||
});
|
||||
return { luma, sonar };
|
||||
}, places);
|
||||
|
||||
// Frames the engine draws in `ms` without being asked.
|
||||
const framesIn = (page, ms) =>
|
||||
page.evaluate(async (ms) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
let frames = 0;
|
||||
const off = engine.frames.postRender.addEventListener(() => frames++);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
off();
|
||||
return frames;
|
||||
}, ms);
|
||||
|
||||
const mean = (values) =>
|
||||
values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: WIDTH, height: HEIGHT });
|
||||
await page.goto(visualPageUrl(base.origin, 'navara'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const places = contactPlaces();
|
||||
const placed = await page.evaluate((places) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const set = engine.movers.createSet({
|
||||
id: 'qa-sonar-contacts',
|
||||
kind: 'point',
|
||||
capacity: places.length,
|
||||
depthTest: 'always',
|
||||
point: { sizePx: 14, outlinePx: 0 },
|
||||
});
|
||||
window.__qaSonarSet = set;
|
||||
let missing = 0;
|
||||
places.forEach((place, index) => {
|
||||
const position = engine.camera.pickEllipsoid(place);
|
||||
if (!position) missing++;
|
||||
set.set(index, { position, color: [1, 1, 1, 1] });
|
||||
});
|
||||
set.setCount(places.length);
|
||||
set.commit();
|
||||
return { missing, sonar: engine.render.contactSonar?.() ?? null };
|
||||
}, places);
|
||||
check(
|
||||
'Navara treats contacts itself; the sonar starts off',
|
||||
placed.missing === 0 && placed.sonar?.enabled === false,
|
||||
placed,
|
||||
);
|
||||
const baseline = await read(page, places);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
// Normal keeps the contacts' own colours; the HUD stays on (in Normal
|
||||
// it would hide itself, and the sweep with it).
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('on');
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setCyberSonarSetting('opacity', 35);
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const theme = await page.evaluate(() => ({
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
hud: document.getElementById('intel-hud')?.classList.contains('active'),
|
||||
}));
|
||||
check(
|
||||
'the Cyber HUD runs with the contact adapter',
|
||||
theme.theme === 'cyber' && theme.gpu === 'supported' && theme.hud,
|
||||
theme,
|
||||
);
|
||||
|
||||
const sweeps = [];
|
||||
for (const wait of [0, 1300, 1300]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
const { luma, sonar } = await read(page, places);
|
||||
const expected = places.map(({ x, y }) =>
|
||||
sonarFactorAt(x, y, WIDTH, HEIGHT, sonar),
|
||||
);
|
||||
const lit = luma.filter((_, i) => expected[i] > 0.9);
|
||||
const dim = luma.filter((_, i) => expected[i] <= sonar.opacity / 100);
|
||||
sweeps.push({
|
||||
spread: Math.max(...luma.slice(0, 32)) - Math.min(...luma.slice(0, 32)),
|
||||
angle: sonar.angleDeg,
|
||||
lit: lit.length,
|
||||
litLuma: mean(lit),
|
||||
dimLuma: mean(dim),
|
||||
brightest: luma.indexOf(Math.max(...luma)),
|
||||
expectedBrightest: expected.indexOf(Math.max(...expected)),
|
||||
sonar,
|
||||
});
|
||||
check(
|
||||
`the sweep at ${Math.round(sonar.angleDeg)}° lights the contacts it passes, the rest sit at the floor`,
|
||||
sonar.enabled &&
|
||||
sonar.active &&
|
||||
sonar.opacity === 35 &&
|
||||
lit.length >= 1 &&
|
||||
dim.length >= 8 &&
|
||||
mean(lit) > mean(dim) + 20 &&
|
||||
expected[places.length - 1] === 0.35,
|
||||
sweeps.at(-1),
|
||||
);
|
||||
}
|
||||
check(
|
||||
'the sweep turns: another contact is lit',
|
||||
new Set(sweeps.map((s) => s.expectedBrightest)).size > 1 &&
|
||||
new Set(sweeps.map((s) => s.brightest)).size > 1,
|
||||
sweeps.map(({ angle, brightest, expectedBrightest }) => ({
|
||||
angle,
|
||||
brightest,
|
||||
expectedBrightest,
|
||||
})),
|
||||
);
|
||||
const cadence = await framesIn(page, 1500);
|
||||
check(
|
||||
'a parked scene draws at the fleet cadence while the sweep runs over contacts',
|
||||
cadence >= 8,
|
||||
{ frames: cadence },
|
||||
);
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setCyberSonarEnabled(false),
|
||||
);
|
||||
const neutral = await read(page, places);
|
||||
const spread =
|
||||
Math.max(...neutral.luma.slice(0, 32)) -
|
||||
Math.min(...neutral.luma.slice(0, 32));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const idle = await framesIn(page, 1500);
|
||||
check(
|
||||
'with the sweep off every contact takes the neutral factor and the scene rests',
|
||||
neutral.sonar.enabled &&
|
||||
!neutral.sonar.active &&
|
||||
// What still differs is the map under the translucent contacts.
|
||||
spread < Math.min(...sweeps.map((sweep) => sweep.spread)) * 0.6 &&
|
||||
idle <= 3,
|
||||
{ spread, sweeping: sweeps.map((sweep) => sweep.spread), frames: idle },
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setCyberSonarEnabled(true);
|
||||
styleManager._setHudVariant('tactical');
|
||||
styleManager.setStyle('normal');
|
||||
styleManager.hud.setMode('auto');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
const after = await read(page, places);
|
||||
const drift = Math.max(
|
||||
...after.luma.map((value, i) => Math.abs(value - baseline.luma[i])),
|
||||
);
|
||||
check(
|
||||
'leaving the Cyber HUD gives the contacts back unchanged',
|
||||
after.sonar.enabled === false && drift < 3,
|
||||
{ drift },
|
||||
);
|
||||
await page.evaluate(() => window.__qaSonarSet.destroy());
|
||||
|
||||
// Cesium draws contact sonar through its engine command adapter.
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await page.evaluate(() => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setHudVariant('cyber');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.cyberSonarGpu,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const cesium = await page.evaluate(() => ({
|
||||
setContactSonar: typeof window.__godsEyeView.engine.render.setContactSonar,
|
||||
gpu: document.documentElement.dataset.cyberSonarGpu,
|
||||
}));
|
||||
check(
|
||||
'Cesium: the engine command adapter treats contacts',
|
||||
cesium.setContactSonar === 'function' && cesium.gpu === 'supported',
|
||||
cesium,
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager._setHudVariant('tactical'),
|
||||
);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'sonar.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
// The app's visual styles on Navara: each of CRT, NVG, FLIR,
|
||||
// Anime and Noir filters the Navara frame (pixels and a style-specific
|
||||
// signature against Normal), switching styles crossfades (both styles drawn
|
||||
// at once mid-way, the fade completes), Normal restores the frame, the
|
||||
// retired Snow opens on Normal, a link's style opens on Navara, and on
|
||||
// Cesium the styles still run as Cesium post stages with the engine left
|
||||
// alone. Keyless preview under the strict default-deny network policy.
|
||||
//
|
||||
// node scripts/qa-navara-visual-styles.mjs http://127.0.0.1:<PORT>/ [--out=DIR]
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createStrict,
|
||||
localPreview,
|
||||
waitForEngine,
|
||||
} from './qa-strict-harness.mjs';
|
||||
import {
|
||||
STILL_VIEW,
|
||||
capture,
|
||||
checker,
|
||||
diff,
|
||||
freeze,
|
||||
histogramShift,
|
||||
pickStyle,
|
||||
pinDaytime,
|
||||
saveCapture,
|
||||
stats,
|
||||
visualPageUrl,
|
||||
} from './qa/navaraVisual.mjs';
|
||||
|
||||
const base = localPreview(process.argv[2]);
|
||||
const outDir = process.argv
|
||||
.find((arg) => arg.startsWith('--out='))
|
||||
?.slice('--out='.length);
|
||||
const STYLES = ['retro', 'surveillance', 'thermal', 'anime', 'noir'];
|
||||
const URL_NAMES = { retro: 'crt', surveillance: 'nvg', thermal: 'flir' };
|
||||
const pageUrl = (engine, extra = '') =>
|
||||
visualPageUrl(base.origin, engine, extra, STILL_VIEW);
|
||||
|
||||
const strict = createStrict({ origin: base.origin, layers: [] });
|
||||
const report = { checks: [] };
|
||||
const check = checker(report, assert);
|
||||
|
||||
const save = (name, pixels) => saveCapture(outDir, name, pixels);
|
||||
|
||||
await strict.run({}, async (browser) => {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
// The same sun on every run: the frame's brightness decides what blooms
|
||||
// and what each style's signature reads.
|
||||
await pinDaytime(page);
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||
});
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(pageUrl('navara'), { waitUntil: 'domcontentloaded' });
|
||||
await waitForEngine(page, 'navara');
|
||||
await freeze(page);
|
||||
|
||||
const capabilities = await page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
return {
|
||||
styles: engine.render.visualStyles,
|
||||
pipeline: engine.pipeline(),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Navara draws every app style itself',
|
||||
JSON.stringify(capabilities.styles) === JSON.stringify(STYLES),
|
||||
capabilities,
|
||||
);
|
||||
check(
|
||||
'no style pass shows on Normal',
|
||||
capabilities.pipeline?.shown === false,
|
||||
capabilities.pipeline,
|
||||
);
|
||||
|
||||
// The military styles' presets set sharpening and detection (as on
|
||||
// Cesium); Normal keeps them, so they are put back before comparing.
|
||||
const display = await page.evaluate(
|
||||
() => window.__godsEyeView.styleManager.getControlState().sharpen,
|
||||
);
|
||||
const normal = await capture(page);
|
||||
const normalAgain = await capture(page);
|
||||
const noise = diff(normal, normalAgain);
|
||||
check('Normal is a still frame to compare against', noise.mean < 0.5, noise);
|
||||
save('normal', normal);
|
||||
const normalStats = stats(normal);
|
||||
|
||||
let previous = 'normal';
|
||||
for (const name of STYLES) {
|
||||
const fade = await pickStyle(page, name, previous);
|
||||
check(
|
||||
`${name}: the crossfade completes`,
|
||||
fade.pending === 0 && fade.ms < 1500 && fade.active === name,
|
||||
{ ms: fade.ms, pending: fade.pending },
|
||||
);
|
||||
const between = fade.samples.filter(
|
||||
([, next]) => next > 0.02 && next < 0.98,
|
||||
);
|
||||
check(
|
||||
`${name}: the engine fades it in over several frames`,
|
||||
between.length >= 1,
|
||||
{ frames: fade.samples.length, between: between.length },
|
||||
);
|
||||
if (previous !== 'normal') {
|
||||
const both = fade.samples.filter(
|
||||
([out, next]) => out > 0.05 && out < 0.95 && next > 0.05 && next < 0.95,
|
||||
);
|
||||
check(
|
||||
`${name}: ${previous} and ${name} draw together mid-crossfade`,
|
||||
both.length >= 1,
|
||||
{ both: both.length },
|
||||
);
|
||||
}
|
||||
const others = Object.entries(fade.intensities).filter(
|
||||
([style, value]) => style !== name && value !== 0,
|
||||
);
|
||||
check(
|
||||
`${name}: alone at full intensity once settled`,
|
||||
fade.intensities[name] === 1 && others.length === 0,
|
||||
fade.intensities,
|
||||
);
|
||||
const order = fade.pipeline?.passes ?? [];
|
||||
const at = order.indexOf('gevStyles');
|
||||
check(
|
||||
`${name}: the style pass shows, last before the final copy and after the overlays`,
|
||||
fade.pipeline?.shown === true &&
|
||||
at >= 0 &&
|
||||
order[at + 1] === 'final' &&
|
||||
['toneMapping', 'gevSharpen', 'gevOverlay', 'smaa', 'fxaa'].every(
|
||||
(key) => !order.includes(key) || order.indexOf(key) < at,
|
||||
),
|
||||
fade.pipeline,
|
||||
);
|
||||
const pixels = await capture(page);
|
||||
save(name, pixels);
|
||||
const styled = stats(pixels);
|
||||
const change = diff(normal, pixels);
|
||||
const shift = histogramShift(normalStats, styled);
|
||||
check(
|
||||
`${name}: the frame changes against Normal`,
|
||||
change.mean > 6 && change.changedShare > 0.25,
|
||||
{ ...change, histogramShift: shift },
|
||||
);
|
||||
const signature = {
|
||||
// Night vision: phosphor green over red and blue.
|
||||
surveillance: styled.g > styled.r * 1.3 && styled.g > styled.b * 1.3,
|
||||
// FLIR white-hot (palette 0): grey.
|
||||
thermal: styled.saturation < normalStats.saturation * 0.5,
|
||||
// Noir: greyer, with a slight sepia, and vignetted corners.
|
||||
noir:
|
||||
styled.saturation < normalStats.saturation &&
|
||||
styled.vignette < normalStats.vignette * 0.85,
|
||||
// Anime: cel bands, more saturated.
|
||||
anime: styled.saturation > normalStats.saturation * 1.05 || shift > 0.05,
|
||||
// CRT: pixelated, dithered, scanlined.
|
||||
retro: shift > 0.05 || change.mean > 12,
|
||||
}[name];
|
||||
check(`${name}: the style's own look`, signature, {
|
||||
normal: {
|
||||
r: normalStats.r,
|
||||
g: normalStats.g,
|
||||
b: normalStats.b,
|
||||
saturation: normalStats.saturation,
|
||||
vignette: normalStats.vignette,
|
||||
},
|
||||
styled: {
|
||||
r: styled.r,
|
||||
g: styled.g,
|
||||
b: styled.b,
|
||||
saturation: styled.saturation,
|
||||
vignette: styled.vignette,
|
||||
},
|
||||
histogramShift: shift,
|
||||
});
|
||||
previous = name;
|
||||
}
|
||||
|
||||
// A style parameter reaches the Navara pass (FLIR's Ironbow palette).
|
||||
await pickStyle(page, 'thermal', previous);
|
||||
previous = 'thermal';
|
||||
const grey = await capture(page);
|
||||
const ironbow = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
a.styleManager.stages.thermal.uniforms.palette = 1;
|
||||
return a.engine.render.style('thermal').uniforms.palette;
|
||||
});
|
||||
const coloured = await capture(page);
|
||||
check(
|
||||
'FLIR Ironbow reaches the engine and colours the frame',
|
||||
ironbow === 1 && stats(coloured).saturation > stats(grey).saturation + 10,
|
||||
{
|
||||
grey: stats(grey).saturation,
|
||||
ironbow: stats(coloured).saturation,
|
||||
},
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
window.__godsEyeView.styleManager.stages.thermal.uniforms.palette = 0;
|
||||
});
|
||||
|
||||
const back = await pickStyle(page, 'normal', previous);
|
||||
check(
|
||||
'Normal: every style back at 0 and the pass hidden',
|
||||
Object.values(back.intensities).every((v) => v === 0) &&
|
||||
back.pipeline?.shown === false,
|
||||
back,
|
||||
);
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const restored = await capture(page);
|
||||
save('normal-restored', restored);
|
||||
const residue = diff(normal, restored);
|
||||
check(
|
||||
'Normal restores the frame',
|
||||
residue.mean < 1 && residue.changedShare < 0.01,
|
||||
residue,
|
||||
);
|
||||
|
||||
// The retired Snow opens on Normal; a link's style opens on Navara.
|
||||
await page.goto(pageUrl('navara', '&style=snow'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
const snow = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
return {
|
||||
active: a.styleManager.activeStyle,
|
||||
intensities: a.engine.render.visualStyles.map(
|
||||
(s) => a.engine.render.style(s).intensity,
|
||||
),
|
||||
};
|
||||
});
|
||||
check(
|
||||
'style=snow opens on Normal',
|
||||
snow.active === 'normal' && snow.intensities.every((v) => v === 0),
|
||||
snow,
|
||||
);
|
||||
await page.goto(pageUrl('navara', `&style=${URL_NAMES.thermal}`), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await waitForEngine(page, 'navara');
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.engine.render.style('thermal').intensity === 1,
|
||||
{ timeout: 10000, polling: 50 },
|
||||
);
|
||||
check('a FLIR link opens filtered on Navara', true);
|
||||
|
||||
// Cesium keeps its own post stages; its engine draws no styles. The
|
||||
// switch is real input on the picker, in the same document.
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setStyle('normal'),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView.styleManager.transitions.size === 0,
|
||||
);
|
||||
await strict.switchTo(page, 'cesium');
|
||||
await freeze(page);
|
||||
// The address may still carry the FLIR link: Normal first.
|
||||
await pickStyle(page, 'normal', 'thermal');
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const cesiumNormal = await capture(page);
|
||||
const cesium = await pickStyle(page, 'thermal', 'normal');
|
||||
const cesiumState = await page.evaluate(() => {
|
||||
const a = window.__godsEyeView;
|
||||
const stage = a.styleManager.stages.thermal;
|
||||
return {
|
||||
setStyle: typeof a.engine.render.setStyle,
|
||||
stageName: stage.name,
|
||||
enabled: stage.enabled,
|
||||
intensity: stage.uniforms.intensity,
|
||||
inScene: a.engine.render.style('thermal').intensity > 0,
|
||||
};
|
||||
});
|
||||
const cesiumThermal = await capture(page);
|
||||
save('cesium-normal', cesiumNormal);
|
||||
save('cesium-thermal', cesiumThermal);
|
||||
if (outDir)
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, 'cesium-thermal-page.png'),
|
||||
});
|
||||
check(
|
||||
'Cesium: the engine owns the FLIR post stage',
|
||||
cesium.pending === 0 &&
|
||||
cesiumState.setStyle === 'function' &&
|
||||
cesiumState.enabled === true &&
|
||||
cesiumState.intensity === 1 &&
|
||||
cesiumState.inScene !== false,
|
||||
cesiumState,
|
||||
);
|
||||
const cesiumChange = diff(cesiumNormal, cesiumThermal);
|
||||
check('Cesium: FLIR changes its frame', cesiumChange.mean > 6, cesiumChange);
|
||||
await pickStyle(page, 'normal', 'thermal');
|
||||
await page.evaluate((sharpen) => {
|
||||
const { styleManager } = window.__godsEyeView;
|
||||
styleManager._setDetectionMode?.('OFF');
|
||||
styleManager.setSharpen(sharpen);
|
||||
}, display);
|
||||
const cesiumBack = diff(cesiumNormal, await capture(page));
|
||||
check('Cesium: Normal restores its frame', cesiumBack.mean < 1.5, cesiumBack);
|
||||
|
||||
check('no page errors', errors.length === 0, errors);
|
||||
if (outDir)
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, 'visual-styles.json'),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
});
|
||||
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user