mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/main' into german/hal-642-ja
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
c493eb0126
78 files changed
+3326
-303
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- Director clock listeners, interaction observers and scene release failures
|
||||
no longer leave the director inconsistent: each scene is released once, and
|
||||
a phase error is kept (with the release failure logged) when cleanup also
|
||||
fails (deepeshbind, #1014).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Keep Directions route errors, the Local ADS-B receiver error and Global
|
||||
Context cohort reasons English in layer data; the Layers panel, Radio card
|
||||
and awareness panel still show them in the interface language. The route
|
||||
proxy's "routing busy" and "route proxy error" refusals are now translated
|
||||
too (daikaginza, #1209).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Pause the HUD's camera readout while the page is hidden, as polled layers
|
||||
already do. An app embedded inline in a panel keeps both updating, because the
|
||||
panel's host can report the page as hidden while it is on screen (nu-stats,
|
||||
#1344).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Restore a shot's declared layer visibility when seeking within it after manual
|
||||
layer changes, including toggles still in flight. Only changed layers are
|
||||
reconciled; refused restoration reports a failed seek and keeps the last
|
||||
position, while undeclared operator layers are preserved (@oneill9, #1374).
|
||||
@@ -0,0 +1,2 @@
|
||||
- A "place, region" search such as "Paris, Texas" now lands on the place in
|
||||
that region instead of a namesake elsewhere (quanticsoul4772, #1401).
|
||||
@@ -0,0 +1,5 @@
|
||||
- The terrain proxy stops asking the upstream for a while after a 429
|
||||
(its Retry-After, at most 10 minutes, or 60 s) instead of retrying on every
|
||||
page request, and a Retry-After longer than its 10 s retry budget now ends
|
||||
the retries at once instead of sleeping the budget out (quanticsoul4772,
|
||||
#1402).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Fire Perimeters and `get_fire_perimeters` say when WFIGS is unreachable and
|
||||
the last snapshot is being shown (STALE, its age, "data may be stale"), and
|
||||
that snapshot is served for at most 24 hours (quanticsoul4772, #1404).
|
||||
@@ -0,0 +1,3 @@
|
||||
- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves
|
||||
cached flow tiles (budget spent or TomTom failing), and the row dates them by
|
||||
the oldest cached tile (quanticsoul4772, #1405).
|
||||
@@ -0,0 +1,5 @@
|
||||
- Cut the Google requests the HUD's AI summary makes on its own. It no longer
|
||||
looks up place names when no OpenAI key is configured, reverse-geocodes at
|
||||
most two points instead of up to four, and looks up again only once the view
|
||||
shows a different area, not after every small pan, so a small pan no longer
|
||||
asks OpenAI for a new summary either (samehkhamis, #0000).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Update the development tools to Vite 6.4.4 and Puppeteer 25.16.0, which
|
||||
clears the open npm advisories for the dev server and the browser test
|
||||
tooling, and remove the unused local screenshot helper `scripts/shot-sink.mjs`
|
||||
(samehkhamis, #1463).
|
||||
+58
-4
@@ -4,6 +4,24 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so
|
||||
containers can read it on enforcing SELinux hosts. The separate image-owned
|
||||
`node_modules` volume and loopback-only port publishing remain unchanged.
|
||||
|
||||
The HUD's AI summary line looks up place names for the view only when the
|
||||
summary can use them. The page asks `GET /api/openai/hud-summary/status` once;
|
||||
a server without an OpenAI key answers `{ configured: false }`, and the HUD then
|
||||
makes no reverse-geocode or Nearby Places request until the page reloads. A
|
||||
server without the route is asked once, and an unconfigured summary reply
|
||||
stops the lookups the same way. Each lookup reverse-geocodes at most two
|
||||
points: the view center alone below 10 km, the center and one viewport point
|
||||
up to 750 km, and two viewport points up to 3000 km; Nearby Places is still
|
||||
added below 25 km. A lookup is reused until the view center moves a quarter of
|
||||
the visible width, the camera's distance to the center doubles or halves, or
|
||||
the camera crosses one of those heights. The width is measured from the
|
||||
camera's distance to the view center, so terrain and tilt scale it. A reused
|
||||
lookup asks again only for what it did not get (a timeout, a failure, an empty
|
||||
answer); everything else is a cache hit. A small pan over a reused lookup
|
||||
repaints the last AI reply instead of asking for a new one. A host passing
|
||||
`cachedOnly` keeps reading every cached sample. Voice context lookups are
|
||||
unchanged.
|
||||
|
||||
The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to
|
||||
hundredths of a second, and then splits it. A value within 0.005" of the next
|
||||
whole minute carries into the minutes and degrees, so `-122.35` reads
|
||||
@@ -560,6 +578,11 @@ dateline while preserving both authored endpoint longitudes, and turns heading
|
||||
and roll the short way for any authored angles.
|
||||
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
||||
the end of the previous one.
|
||||
Same-shot seeking restores declared layer visibility after a manual toggle,
|
||||
including one still in flight, by reconciling only the changed layers through
|
||||
the cancellable layer admission path. Refused restoration reports a failed
|
||||
seek and keeps the last settled position. Undeclared operator layers remain
|
||||
untouched, and the shot is not rebuilt.
|
||||
See [camera directions](DIRECTOR-CAMERA.md).
|
||||
|
||||
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||
@@ -793,6 +816,11 @@ rendered detection set. The legacy manager retains panel helper compatibility;
|
||||
registration sealing, restoration order and failed-transition rollback remain
|
||||
unchanged.
|
||||
|
||||
Polled layer refreshes and the HUD's camera readout pause while the page is
|
||||
hidden. An app embedded inline in a panel keeps both running, because the
|
||||
panel's host can report the page as hidden while it is on screen. This is the
|
||||
same rule the render loop follows (`keepPanelRendering` in `app/embed.js`).
|
||||
|
||||
Application scene setup owns request services, terrain/floor caches and annotation
|
||||
lookup state. Controls, layers and voice share those owners, and cancellation
|
||||
clears caches and removes map-stack listeners. Standalone setup supplies the
|
||||
@@ -1144,6 +1172,10 @@ Shot selection updates the highlight without replacing the row, preserving
|
||||
native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately;
|
||||
late file and failed-action completions cannot update removed presentation.
|
||||
|
||||
Director clock listeners, interaction observers and scene release failures no
|
||||
longer leave the director inconsistent, and a phase error is kept when cleanup
|
||||
also fails.
|
||||
|
||||
## Cockpit component ownership
|
||||
|
||||
Cockpit presentation is separated from its camera/controller behavior. Existing
|
||||
@@ -3078,7 +3110,14 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> usable per point, while an uncached absent height still returns 502 rather
|
||||
> than becoming a fabricated ground value. A position the upstream answers
|
||||
> with a null ellipsoid is reported as an absent height rather than a refresh
|
||||
> failure, and is left uncached so a later poll re-asks it. Client geoid
|
||||
> failure, and is left uncached so a later poll re-asks it. A `Retry-After`
|
||||
> longer than the 10 s retry budget ends the retries at once. After a 429 that
|
||||
> outlasts its retries, the proxy pauses upstream calls for the `Retry-After`
|
||||
> (60 seconds when none, at most 10 minutes). A request whose points are all
|
||||
> cached, fresh or stale, is still served; a request with any uncached point
|
||||
> gets 502 and the page uses its geoid fallback for that chunk. Each request
|
||||
> that was waiting on the upstream when the 429 came back logs one warning;
|
||||
> requests refused during the pause do not log. Client geoid
|
||||
> fallbacks wait 60 seconds before retrying and self-heal to Re:Earth on the
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
@@ -3851,7 +3890,7 @@ anchors, recovery, keyboard and mobile states; emergency repainting is covered
|
||||
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
|
||||
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
|
||||
@@ -5267,7 +5306,13 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
|
||||
deadline, with Photon requests capped at six seconds each. Caller/application
|
||||
cancellation stops retries and late cache writes. Replacing a location search
|
||||
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
|
||||
candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers
|
||||
candidates, and an unbiased retry for name mismatches. A "place, region" query
|
||||
prefers a candidate named exactly for the place whose city, county, state,
|
||||
country or country code (or USA/UK) contains every word of the region, and a
|
||||
settlement or administrative area over a POI when both fit. Nearby results that
|
||||
fail that check are retried unbiased. After the region checks of both passes, a
|
||||
nearby place whose whole name is the query (one with a comma in it) comes next,
|
||||
then the unbiased name match. Invalid or wrapped bounds returned by providers
|
||||
are omitted rather than framing the wrong part of the globe.
|
||||
|
||||
A location search that finds nothing, or fails, leaves the field open with the
|
||||
@@ -5758,7 +5803,11 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
coverage number. `stats.loading` covers outstanding flow work as well as the
|
||||
road fetch, so a flow failure landing after the first paint still ends the
|
||||
shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`,
|
||||
never on `mode === 'live'` alone.
|
||||
never on `mode === 'live'` alone. When the drawn flow came from the proxy's
|
||||
cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true`
|
||||
and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the
|
||||
sync chip reads
|
||||
`STALE · Roads: <source> · Flow: cached TomTom tiles` instead of LIVE.
|
||||
- Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY`
|
||||
is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode:
|
||||
TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy
|
||||
@@ -6061,6 +6110,11 @@ reception does not interrupt internet radio.
|
||||
Changes apply to the open receiver without reconnecting.
|
||||
- **Receiver stats (ADS-B).** CRC-valid messages per second, aircraft heard,
|
||||
aircraft with a fresh position and the IQ level.
|
||||
- **Error text.** A receiver error sets two fields (`errorState()` in
|
||||
`src/sdr/controller.js`): `message` is the UI language for the Radio card,
|
||||
`errorText` is the English text the Local ADS-B layer reports as its stats
|
||||
`error` (`localAdsbStatus()`, falling back to `message` when a receiver sets
|
||||
none); the Layers panel translates it where it shows it.
|
||||
- **Devices.** An already-authorized receiver opens without the WebUSB
|
||||
picker. ADS-B prefers a device whose product name matches ADS-B or 1090 (the
|
||||
1090 MHz channel of a dual-channel board); FM avoids ADS-B/UAT channels.
|
||||
|
||||
Generated
+67
-67
@@ -23,9 +23,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.10.0",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"vite": "^6.0.0",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
@@ -1116,13 +1116,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
"integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.6.tgz",
|
||||
"integrity": "sha512-MJP29gVoHznmqmwhH1tlZqzfeQ3aylTpSSa6WKZXtkwYUH02i9imBmWGTyZrbnAou5paX78SE4jMAUqnEPf8ug==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.8.4",
|
||||
"modern-tar": "^0.8.6",
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1620,9 +1620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
"integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1707,14 +1707,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
|
||||
"integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
|
||||
"version": "157.0.8090-0",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-157.0.8090-0.tgz",
|
||||
"integrity": "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||
@@ -1723,16 +1723,6 @@
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
@@ -1821,9 +1811,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1666840",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz",
|
||||
"integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==",
|
||||
"version": "0.0.1704951",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1704951.tgz",
|
||||
"integrity": "sha512-GQhQOs9u/Mvm1zMtJIH872JS4AskuZdUSsPNPk4ChahtWAUqgz6atEtIsMt+nO7MqF1Ic4HxII/k8Cu36JivNg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
@@ -2054,9 +2044,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2361,9 +2351,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/modern-tar": {
|
||||
"version": "0.8.5",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz",
|
||||
"integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==",
|
||||
"version": "0.8.6",
|
||||
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.6.tgz",
|
||||
"integrity": "sha512-LYSks0tNVqbtez/9OB0YFP6qLRSqkdAxJWOQpVaR67lNTbj2zNLyYpowkRMia5lXLCZKzxwoxkxH2MbbBR2aEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2557,19 +2547,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer": {
|
||||
"version": "25.10.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz",
|
||||
"integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==",
|
||||
"version": "25.16.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.16.0.tgz",
|
||||
"integrity": "sha512-LOr3reYnxAYYfQkpopz1fQnL0LdvqhpR5U2Fu+QnwzTX2gpVTHsn91UETo5NNC1md2/oC54Zu1ChBXgZDNSP1A==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"@puppeteer/browsers": "3.2.6",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1704951",
|
||||
"lilconfig": "^3.1.3",
|
||||
"puppeteer-core": "25.10.0",
|
||||
"typed-query-selector": "^2.12.2"
|
||||
"puppeteer-core": "25.16.0",
|
||||
"typed-query-selector": "^2.12.3"
|
||||
},
|
||||
"bin": {
|
||||
"puppeteer": "lib/puppeteer/node/cli.js"
|
||||
@@ -2579,18 +2569,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.10.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz",
|
||||
"integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==",
|
||||
"version": "25.16.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.16.0.tgz",
|
||||
"integrity": "sha512-VA5Pf54N8shfopNkZZXCdiiwfnnY8ZJYAMqFQRhlH5mGUhfwf53Ar770GPtdpTDLUqvQeYpIBtC9/G2Jij2IJw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.2.2",
|
||||
"chromium-bidi": "17.0.2",
|
||||
"devtools-protocol": "0.0.1666840",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.3",
|
||||
"ws": "^8.21.3"
|
||||
"@puppeteer/browsers": "3.2.6",
|
||||
"chromium-bidi": "157.0.8090-0",
|
||||
"devtools-protocol": "0.0.1704951",
|
||||
"typed-query-selector": "^2.12.3",
|
||||
"webdriver-bidi-protocol": "1.0.1",
|
||||
"ws": "^8.22.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
@@ -2871,9 +2861,9 @@
|
||||
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2963,9 +2953,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typed-query-selector": {
|
||||
"version": "2.12.2",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||
"version": "2.12.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz",
|
||||
"integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3002,9 +2992,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"version": "6.4.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
|
||||
"integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3132,9 +3122,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webdriver-bidi-protocol": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz",
|
||||
"integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-1.0.1.tgz",
|
||||
"integrity": "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
@@ -3175,9 +3165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3226,9 +3216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
|
||||
"integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
|
||||
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3252,6 +3242,16 @@
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -70,9 +70,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"puppeteer": "^25.10.0",
|
||||
"puppeteer": "^25.16.0",
|
||||
"sharp": "^0.35.4",
|
||||
"vite": "^6.0.0",
|
||||
"vite": "^6.4.4",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
|
||||
@@ -1102,6 +1102,8 @@
|
||||
"src/cameraGlobeRotation.test.mjs",
|
||||
"src/voice/globeResetConfirmation.test.mjs",
|
||||
"src/hudDms.test.mjs",
|
||||
"src/hudSummaryGate.test.mjs",
|
||||
"src/voice/hudPlaceLabels.test.mjs",
|
||||
"src/voice/panel/preferences.test.mjs",
|
||||
"src/voice/panel/host.test.mjs",
|
||||
"src/voice/panel/manualPanel.test.mjs",
|
||||
|
||||
@@ -1982,6 +1982,7 @@
|
||||
"src/hud.js",
|
||||
"src/hudLocality.js",
|
||||
"src/hudLocalityFormat.js",
|
||||
"src/hudPlaceLookup.js",
|
||||
"src/hudSummaryResponse.js",
|
||||
"src/keySetup.js",
|
||||
"src/keySetupCore.mjs",
|
||||
|
||||
@@ -88,6 +88,70 @@ try {
|
||||
};
|
||||
});
|
||||
for (const [name, passed] of Object.entries(result)) check(name, passed);
|
||||
const visibilityRestored = await page.evaluate(async () => {
|
||||
// QA layer registration exists only in Vite dev builds.
|
||||
if (typeof window.__gevQaRegisterLayer !== 'function') return 'skipped';
|
||||
const { sceneDirector: director, dataManager } = window.__godsEyeView;
|
||||
const layerId = 'director-seek-visibility-qa';
|
||||
window.__gevQaRegisterLayer(dataManager, {
|
||||
id: layerId,
|
||||
name: 'Director seek visibility QA',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
update() {},
|
||||
destroy() {},
|
||||
getStats: () => ({ count: 0 }),
|
||||
});
|
||||
const scene = {
|
||||
id: 'visibility-qa',
|
||||
title: 'Visibility QA',
|
||||
shots: [
|
||||
{
|
||||
id: 'visibility-shot',
|
||||
title: 'Visibility shot',
|
||||
durationSec: 0.4,
|
||||
holdSec: 0,
|
||||
camera: director.styleManager.getCameraState(),
|
||||
visual: director.styleManager.getVisualState(),
|
||||
layers: { [layerId]: { enabled: true } },
|
||||
},
|
||||
],
|
||||
};
|
||||
director._project.scenes.push(scene);
|
||||
try {
|
||||
const loaded = await director.loadShot(scene.id, scene.shots[0].id, {
|
||||
flyDuration: 0.2,
|
||||
});
|
||||
const disabled =
|
||||
(await dataManager.setEnabled(layerId, false, { origin: 'user' })) &&
|
||||
!dataManager.layers.get(layerId).enabled;
|
||||
const sought = await director.seekScene(scene.id, 0.5);
|
||||
return (
|
||||
loaded?.started &&
|
||||
disabled &&
|
||||
sought &&
|
||||
dataManager.layers.get(layerId).enabled
|
||||
);
|
||||
} finally {
|
||||
director.stopScene();
|
||||
director._project.scenes = director._project.scenes.filter(
|
||||
({ id }) => id !== scene.id,
|
||||
);
|
||||
await window.__gevQaUnregisterLayer(dataManager, layerId);
|
||||
}
|
||||
});
|
||||
if (visibilityRestored === 'skipped') {
|
||||
console.log(
|
||||
'[SKIP] same-shot seek visibility needs a Vite dev server (QA_BASE_URL)',
|
||||
);
|
||||
} else {
|
||||
check(
|
||||
'same-shot seek restores manually changed layer visibility',
|
||||
visibilityRestored,
|
||||
);
|
||||
}
|
||||
const content = await page.evaluate(async () => {
|
||||
const d = window.__godsEyeView.sceneDirector;
|
||||
const scene = d._project.scenes.find(({ title }) =>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal screenshot sink: the browser POSTs a data:image/... URL (grabbed from
|
||||
// the real-GPU Cesium canvas) and this writes it to qa-shots/height-datum/<name>.
|
||||
// Keeps large image payloads OUT of the agent's context — browser → sink → disk.
|
||||
// Usage: node scripts/shot-sink.mjs (listens on :4399)
|
||||
import http from 'node:http';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'qa-shots', 'height-datum');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
const PORT = 4399;
|
||||
|
||||
const cors = (res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
};
|
||||
|
||||
http.createServer((req, res) => {
|
||||
cors(res);
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (req.method === 'POST' && url.pathname === '/save') {
|
||||
const name = (url.searchParams.get('name') || 'shot').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const m = /^data:image\/(png|jpeg);base64,(.+)$/s.exec(body.trim());
|
||||
if (!m) { res.writeHead(400); res.end('bad dataurl'); return; }
|
||||
const ext = m[1] === 'jpeg' ? 'jpg' : 'png';
|
||||
const file = path.join(OUT, `${name}.${ext}`);
|
||||
writeFileSync(file, Buffer.from(m[2], 'base64'));
|
||||
res.writeHead(200); res.end(file);
|
||||
console.log(`saved ${file} (${Math.round(m[2].length / 1024)} KB b64)`);
|
||||
} catch (e) { res.writeHead(500); res.end(String(e)); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404); res.end('nope');
|
||||
}).listen(PORT, () => console.log(`shot-sink on http://localhost:${PORT} → ${OUT}`));
|
||||
@@ -39,6 +39,8 @@ const API_URL =
|
||||
// The service caps a single response at its maxRecordCount (2000); a peak
|
||||
// season can exceed that, so follow exceededTransferLimit with offset pages.
|
||||
const MAX_PAGES = 5;
|
||||
/** Longest a failed refresh keeps serving the last perimeter snapshot, marked stale. */
|
||||
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
@@ -165,7 +167,14 @@ export function firePerimetersProxy({
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
if (previous) return { value: previous.value, stale: true };
|
||||
// A failed refresh serves the last answer, marked stale. A perimeter
|
||||
// snapshot only for a day: older than that, it is no picture of
|
||||
// current fires. (InciWeb entries keep their previous behaviour.)
|
||||
if (
|
||||
previous &&
|
||||
(key !== 'perimeters' || now() - previous.savedAt <= STALE_MAX_MS)
|
||||
)
|
||||
return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@ function toFiveWordHudSummary(value) {
|
||||
}
|
||||
|
||||
async function handleHudSummary(req, res) {
|
||||
// Capability probe: lets the HUD skip its paid place lookups when there is
|
||||
// no summary to feed them to.
|
||||
if (req.method === 'GET' && req.url?.split('?')[0] === '/status') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
configured: !keylessHudSummaryResponse(process.env.OPENAI_API_KEY),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
parseTerrainPoints,
|
||||
resolveTerrainHeightRequest,
|
||||
terrainPointKey,
|
||||
terrainRetryAfterMs,
|
||||
validTerrainResult,
|
||||
} from '../../src/data/terrainHeightsProxy.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
@@ -55,6 +56,15 @@ export function terrainHeightsProxy(options = {}) {
|
||||
// 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of
|
||||
// that range.
|
||||
const UPSTREAM_CHUNK = 64;
|
||||
// After a 429 the upstream is left alone for its Retry-After (at most
|
||||
// RATE_LIMIT_PAUSE_MAX_MS, so a far-off date can't stop terrain until a
|
||||
// restart), or RATE_LIMIT_PAUSE_MS when it gives none. Every page poll for
|
||||
// new points (about once a second while the camera moves) otherwise asks
|
||||
// again with its own retries, which keeps the limit in force and logs each
|
||||
// time.
|
||||
const RATE_LIMIT_PAUSE_MS = 60_000;
|
||||
const RATE_LIMIT_PAUSE_MAX_MS = 10 * 60_000;
|
||||
let rateLimitedUntil = 0;
|
||||
const MAX_POINTS = UPSTREAM_CHUNK;
|
||||
/**
|
||||
* Entry ceiling for the point cache, and with it the file written from it.
|
||||
@@ -199,7 +209,8 @@ export function terrainHeightsProxy(options = {}) {
|
||||
*
|
||||
* The first try keeps its empirically required 30s timeout; network errors,
|
||||
* 429, and 5xx receive up to three jittered retries sharing a 10s added-time
|
||||
* budget, with Retry-After honored within that bound.
|
||||
* budget, with Retry-After honored within that bound; a longer Retry-After
|
||||
* ends the retries at once so the 429 pause below starts without delay.
|
||||
*
|
||||
* A chunk that still fails yields nulls for its own positions rather than
|
||||
* rejecting: with several chunks per request, throwing would discard every
|
||||
@@ -211,15 +222,37 @@ export function terrainHeightsProxy(options = {}) {
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async function fetchUpstreamAll(points) {
|
||||
if (Date.now() < rateLimitedUntil) {
|
||||
// Rate-limited: asking again only extends the limit. Cached points are
|
||||
// still served; the rest resolve on the page through its geoid.
|
||||
const error = new Error('HTTP 429');
|
||||
error.status = 429;
|
||||
error.coolingDown = true;
|
||||
throw error;
|
||||
}
|
||||
const results = [];
|
||||
let firstError = null;
|
||||
for (let i = 0; i < points.length; i += UPSTREAM_CHUNK) {
|
||||
const chunk = points.slice(i, i + UPSTREAM_CHUNK);
|
||||
let chunkResults = [];
|
||||
try {
|
||||
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||
} catch (error) {
|
||||
firstError = firstError || error;
|
||||
// Re-checked per chunk for when a request spans several chunks again;
|
||||
// today requests are capped at one chunk (MAX_POINTS).
|
||||
if (Date.now() >= rateLimitedUntil) {
|
||||
try {
|
||||
chunkResults = await fetchTerrainChunkWithRetry(chunk);
|
||||
} catch (error) {
|
||||
firstError = firstError || error;
|
||||
// Concurrent 429s extend the pause, never cut it short.
|
||||
if (error?.status === 429)
|
||||
rateLimitedUntil = Math.max(
|
||||
rateLimitedUntil,
|
||||
Date.now() +
|
||||
Math.min(
|
||||
terrainRetryAfterMs(error.retryAfter) ?? RATE_LIMIT_PAUSE_MS,
|
||||
RATE_LIMIT_PAUSE_MAX_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep later chunks aligned even if a malformed upstream response omits
|
||||
// trailing positions. The resolver will reject each null individually.
|
||||
@@ -292,9 +325,17 @@ export function terrainHeightsProxy(options = {}) {
|
||||
evictOverflow();
|
||||
diskDirty = true;
|
||||
}
|
||||
if (outcome.upstreamError) {
|
||||
if (outcome.upstreamError?.coolingDown) {
|
||||
// Logged once, when the pause began.
|
||||
} else if (outcome.upstreamError) {
|
||||
const pause =
|
||||
outcome.upstreamError.status === 429 &&
|
||||
Date.now() < rateLimitedUntil
|
||||
? ` (HTTP 429; pausing upstream for ${Math.ceil((rateLimitedUntil - Date.now()) / 1000)} s)`
|
||||
: '';
|
||||
console.warn(
|
||||
'[terrain-heights-proxy] refresh incomplete' +
|
||||
pause +
|
||||
' — serving stale points when available',
|
||||
);
|
||||
} else if (outcome.absentPoints > 0) {
|
||||
|
||||
@@ -94,6 +94,35 @@ test('data setup seals the caller catalog before controls can restore and drains
|
||||
assert.equal(dataManager.layers.size, 0);
|
||||
});
|
||||
|
||||
test('data setup keeps layers polling in an inline panel its host reports hidden', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
getElementById: () => null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
t.after(() => {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
if (previous === undefined) delete globalThis.document;
|
||||
else globalThis.document = previous;
|
||||
});
|
||||
const releases = [];
|
||||
const { dataManager } = createApplicationData({
|
||||
scene: { viewer: {} },
|
||||
controls: { styleManager: { attachDataManager() {} } },
|
||||
catalog: createLayerCatalog([], []),
|
||||
defer: (release) => releases.push(release),
|
||||
});
|
||||
const visibility = dataManager._visibilityTarget;
|
||||
assert.equal(visibility.hidden, true, 'a hidden page pauses outside a panel');
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
assert.equal(visibility.hidden, false);
|
||||
assert.equal(visibility.visibilityState, 'visible');
|
||||
for (const release of releases.reverse()) await release();
|
||||
});
|
||||
|
||||
test('controls bind catalog instances rather than similarly named defaults', () => {
|
||||
const ids = [
|
||||
'traffic',
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
import { panelAwareVisibility } from './embed.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -13,7 +14,9 @@ export function createApplicationData({
|
||||
// Initialize data layer manager
|
||||
const dataManager = new LayerLifecycle(viewer, {
|
||||
allowQaRegistration,
|
||||
visibilityTarget: document,
|
||||
// A panel's host may report it hidden while it is on screen; its layers
|
||||
// keep polling (see keepPanelRendering in embed.js).
|
||||
visibilityTarget: panelAwareVisibility(document),
|
||||
getSourceAvailability: catalog?.getSourceAvailability,
|
||||
});
|
||||
defer(async () => {
|
||||
|
||||
@@ -45,6 +45,28 @@ export function isEmbeddedInline() {
|
||||
return globalThis.GEV_EMBED_INLINE === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
|
||||
* may report it hidden while it is on screen (see keepPanelRendering), so an
|
||||
* inline panel always reads as visible.
|
||||
*/
|
||||
export function panelAwareVisibility(doc = globalThis.document) {
|
||||
const hidden = () =>
|
||||
(doc.hidden === true || doc.visibilityState === 'hidden') &&
|
||||
!isEmbeddedInline();
|
||||
return {
|
||||
get hidden() {
|
||||
return hidden();
|
||||
},
|
||||
get visibilityState() {
|
||||
return hidden() ? 'hidden' : 'visible';
|
||||
},
|
||||
addEventListener: (type, listener) => doc.addEventListener(type, listener),
|
||||
removeEventListener: (type, listener) =>
|
||||
doc.removeEventListener(type, listener),
|
||||
};
|
||||
}
|
||||
|
||||
// A panel keeps drawing at about this rate when its host stops animation
|
||||
// frames; see keepPanelRendering.
|
||||
const PANEL_FRAME_MS = 33;
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
installViews,
|
||||
isEmbedded,
|
||||
keepPanelRendering,
|
||||
panelAwareVisibility,
|
||||
} from './embed.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
@@ -211,6 +213,60 @@ test('an app loaded inline into a panel page talks through its own window', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
|
||||
const listeners = new Set();
|
||||
const doc = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
};
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||||
};
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
let updates = 0;
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ visibilityTarget: panelAwareVisibility(doc) },
|
||||
);
|
||||
manager.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: 1000,
|
||||
async init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
async update() {
|
||||
updates++;
|
||||
await Promise.resolve();
|
||||
},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
});
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
await manager.setEnabled('flights', true);
|
||||
await settle();
|
||||
const enabled = updates;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'the panel keeps polling');
|
||||
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
t.mock.timers.tick(1000);
|
||||
await settle();
|
||||
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
await manager.destroyAll();
|
||||
}
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||||
const { calls, run } = recorder();
|
||||
const an = encodeURIComponent(
|
||||
|
||||
@@ -728,6 +728,8 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
viewer,
|
||||
_sceneSeekGeneration: 0,
|
||||
_loadGeneration: 0,
|
||||
_bumpSceneSeekGeneration:
|
||||
SceneDirector.prototype._bumpSceneSeekGeneration,
|
||||
_cancelActiveSceneTravel:
|
||||
SceneDirector.prototype._cancelActiveSceneTravel,
|
||||
_setSceneMediaPlayback: SceneDirector.prototype._setSceneMediaPlayback,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Pure proximity helpers for Global Context. No capability modeling. */
|
||||
|
||||
import { t } from '../i18n/index.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
import { UNNAMED_PLACE_NAMES } from './installationNames.js';
|
||||
|
||||
const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES);
|
||||
@@ -84,7 +84,8 @@ export function formatAwarenessLabel(value) {
|
||||
/**
|
||||
* Summarize sorted nearby results without promoting missing/stale feeds to a
|
||||
* negative finding. `UNKNOWN` is an input-data state, never an out-of-range
|
||||
* claim in this MVP.
|
||||
* claim in this MVP. `reason` is English data; the awareness panel shows it
|
||||
* in the UI language.
|
||||
*/
|
||||
export function summarizeAwarenessCohort(
|
||||
items,
|
||||
@@ -95,14 +96,14 @@ export function summarizeAwarenessCohort(
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed unavailable'),
|
||||
reason: msg('feed unavailable'),
|
||||
};
|
||||
if (stale)
|
||||
return {
|
||||
relationship: AWARENESS_RELATIONSHIP.UNKNOWN,
|
||||
count: null,
|
||||
nearest: [],
|
||||
reason: t('feed stale'),
|
||||
reason: msg('feed stale'),
|
||||
};
|
||||
const normalized = (Array.isArray(items) ? items : [])
|
||||
.filter(
|
||||
@@ -119,8 +120,8 @@ export function summarizeAwarenessCohort(
|
||||
count: normalized.length,
|
||||
nearest: normalized.slice(0, limit),
|
||||
reason: normalized.length
|
||||
? t('observed or mapped nearby context')
|
||||
: t('no observed or mapped objects in current feeds'),
|
||||
? msg('observed or mapped nearby context')
|
||||
: msg('no observed or mapped objects in current feeds'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
AWARENESS_RELATIONSHIP,
|
||||
findByDoublingRadius,
|
||||
@@ -47,6 +48,25 @@ test('unavailable and stale feeds remain unknown', () => {
|
||||
assert.equal(summarizeAwarenessCohort([], { stale: true }).reason, 'feed stale');
|
||||
});
|
||||
|
||||
test('cohort reasons stay English in data and translate where the panel shows them', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const reasons = [
|
||||
summarizeAwarenessCohort([], { available: false }).reason,
|
||||
summarizeAwarenessCohort([], { stale: true }).reason,
|
||||
summarizeAwarenessCohort([{ id: 'near', distanceM: 10 }]).reason,
|
||||
summarizeAwarenessCohort([]).reason,
|
||||
];
|
||||
assert.deepEqual(reasons, [
|
||||
'feed unavailable',
|
||||
'feed stale',
|
||||
'observed or mapped nearby context',
|
||||
'no observed or mapped objects in current feeds',
|
||||
]);
|
||||
// The awareness panel shows a reason through translateKnown().
|
||||
for (const reason of reasons) assert.notEqual(translateKnown(reason), reason);
|
||||
});
|
||||
|
||||
test('navigation prioritizes nearby targets from the selected cohort', () => {
|
||||
const cohorts = [
|
||||
{ id: 'flights', summary: { nearest: [{ icao24: 'a1', distanceM: 1000 }, { icao24: 'a2', distanceM: 2000 }] } },
|
||||
|
||||
@@ -107,7 +107,10 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) {
|
||||
/**
|
||||
* Fetch one upstream chunk with jittered exponential backoff. Network errors,
|
||||
* 429, and 5xx retry; other HTTP failures and malformed successes fail fast.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one.
|
||||
* Retry sleeps and retry attempts share a 10s budget after attempt one. A
|
||||
* Retry-After longer than the budget left ends the retries at once: sleeping
|
||||
* out the budget cannot reach the time the upstream asked for, and it would
|
||||
* delay the caller's own back-off (the proxy's 429 pause) by up to 10 s.
|
||||
*
|
||||
* Dependencies are injectable for deterministic offline tests.
|
||||
* @param {Array<[number, number]>} points
|
||||
@@ -145,6 +148,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
const res = await fetchImpl(url, signal ? { signal } : {});
|
||||
if (!res.ok) {
|
||||
const error = new Error(`HTTP ${res.status}`);
|
||||
error.status = res.status;
|
||||
error.retryable = res.status === 429 || res.status >= 500;
|
||||
error.retryAfter = res.headers?.get?.('retry-after') ?? null;
|
||||
throw error;
|
||||
@@ -167,6 +171,7 @@ export async function fetchTerrainChunkWithRetry(
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now());
|
||||
if (retryAfterMs != null && retryAfterMs > remaining) break;
|
||||
const backoffMs = Math.round(
|
||||
350 * 2 ** attempt * (0.75 + random() * 0.5),
|
||||
);
|
||||
|
||||
@@ -91,6 +91,36 @@ test('429 retry honors Retry-After before the next attempt', async () => {
|
||||
assert.equal(results[0].id, 'recovered');
|
||||
});
|
||||
|
||||
test('a Retry-After longer than the retry budget ends the retries without sleeping', async () => {
|
||||
let clock = 3_000_000;
|
||||
let attempts = 0;
|
||||
const sleeps = [];
|
||||
await assert.rejects(
|
||||
fetchTerrainChunkWithRetry([[1, 1]], {
|
||||
now: () => clock,
|
||||
random: () => 0.5,
|
||||
makeSignal: () => undefined,
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
clock += ms;
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
attempts += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (name) => (name.toLowerCase() === 'retry-after' ? '30' : null),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
(error) => error.status === 429 && error.retryAfter === '30',
|
||||
);
|
||||
assert.equal(attempts, 1);
|
||||
assert.deepEqual(sleeps, [], 'no sleep that cannot reach the Retry-After');
|
||||
});
|
||||
|
||||
test('permanently failing upstream exhausts retries and returns 502 without fake results', async () => {
|
||||
let clock = 2_000_000;
|
||||
let attempts = 0;
|
||||
|
||||
@@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot(
|
||||
{
|
||||
getFlowSessionStats: () => ({ tilesFetched: 0 }),
|
||||
resetFlowTileCache: () => {},
|
||||
// The layer reads stale cached tiles through this; a source without it
|
||||
// answers with fresh segments only.
|
||||
fetchFlowDetail: async (...args) => ({
|
||||
segments: await sourceSlot.source.fetchFlowForBounds(...args),
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
export const configureTrafficSource = sourceSlot.configure;
|
||||
/** The source the traffic layer reads, as configured. */
|
||||
export const trafficSource = sourceSlot.source;
|
||||
const layer = createApplicationTraffic({
|
||||
source: sourceSlot.source,
|
||||
});
|
||||
|
||||
+105
-19
@@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js';
|
||||
const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/;
|
||||
|
||||
test('a superseded flow fetch is not an outage', () => {
|
||||
assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null);
|
||||
assert.equal(
|
||||
deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(deriveTrafficFlowError(null), null);
|
||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||
});
|
||||
@@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => {
|
||||
test('flow failures map onto short, specific reasons', () => {
|
||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable');
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)');
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 429'),
|
||||
'TomTom daily budget reached',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 502'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 504'),
|
||||
'TomTom upstream unreachable',
|
||||
);
|
||||
assert.equal(
|
||||
reason('flow tile 12/1/1: HTTP 418'),
|
||||
'TomTom flow error (HTTP 418)',
|
||||
);
|
||||
assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable');
|
||||
});
|
||||
|
||||
@@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => {
|
||||
trafficFeedPresentation({ liveMode: false, fetching: false }),
|
||||
trafficFeedPresentation({ liveMode: false, fetching: true }),
|
||||
trafficFeedPresentation({ statusUnavailable: true }),
|
||||
trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
fetching: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
].map((feed) => feed.loadingLabel);
|
||||
for (const label of labels) {
|
||||
assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`);
|
||||
assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`);
|
||||
assert.ok(
|
||||
label.startsWith('SIMULATED'),
|
||||
`fallback label must lead with the mode: ${label}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('simulating because the status probe failed reads differently from keyless by design', () => {
|
||||
const probeDown = trafficFeedPresentation({ statusUnavailable: true });
|
||||
assert.equal(probeDown.mode, 'sim');
|
||||
assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable');
|
||||
assert.equal(
|
||||
probeDown.loadingLabel,
|
||||
'SIMULATED — traffic service unreachable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
@@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => {
|
||||
assert.deepEqual(idle, {
|
||||
mode: 'live',
|
||||
error: null,
|
||||
loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
loadingLabel:
|
||||
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated',
|
||||
});
|
||||
assert.equal(
|
||||
trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel,
|
||||
@@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag
|
||||
fetching: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
});
|
||||
assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight');
|
||||
assert.deepEqual(
|
||||
busy,
|
||||
down,
|
||||
'the degraded state reads the same whether or not a load is in flight',
|
||||
);
|
||||
});
|
||||
|
||||
test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
@@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
assert.equal(
|
||||
mgr._buildMetaText({
|
||||
source: 'OpenStreetMap',
|
||||
stats: stats(trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
})),
|
||||
stats: stats(
|
||||
trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom daily budget reached',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached',
|
||||
);
|
||||
@@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => {
|
||||
test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => {
|
||||
const settled = { count: 4200, lastUpdate: Date.now() };
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: false }),
|
||||
}),
|
||||
'fallback',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }),
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true }),
|
||||
}),
|
||||
'nominal',
|
||||
);
|
||||
assert.equal(
|
||||
layerFeedState({
|
||||
...settled,
|
||||
...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }),
|
||||
...trafficFeedPresentation({
|
||||
liveMode: true,
|
||||
flowError: 'TomTom flow unavailable',
|
||||
}),
|
||||
}),
|
||||
'degraded',
|
||||
);
|
||||
@@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => {
|
||||
const stats = trafficLayer.getStats();
|
||||
assert.equal(stats.mode, 'sim');
|
||||
assert.equal(stats.error, null);
|
||||
assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`);
|
||||
assert.ok(
|
||||
!LIVE_CLAIM.test(stats.loadingLabel),
|
||||
`boot label implies live data: ${stats.loadingLabel}`,
|
||||
);
|
||||
assert.equal(layerFeedState(stats), 'fallback');
|
||||
});
|
||||
|
||||
@@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => {
|
||||
assert.match(feed.loadingLabel, /TomTom \(no matches\)/);
|
||||
assert.ok(!LIVE_CLAIM.test(feed.loadingLabel));
|
||||
});
|
||||
|
||||
test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => {
|
||||
const { trafficSource, configureTrafficSource } =
|
||||
await import('./traffic.js');
|
||||
assert.equal(typeof trafficSource.fetchFlowDetail, 'function');
|
||||
const base = {
|
||||
requestRoads: async () => ({}),
|
||||
getStatus: async () => ({}),
|
||||
fetchFlowForBounds: async () => ['segment'],
|
||||
};
|
||||
let release = configureTrafficSource({
|
||||
...base,
|
||||
fetchFlowDetail: async () => ({
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
// A source without fetchFlowDetail answers with fresh segments.
|
||||
release = configureTrafficSource(base);
|
||||
try {
|
||||
assert.deepEqual(await trafficSource.fetchFlowDetail({}), {
|
||||
segments: ['segment'],
|
||||
stale: false,
|
||||
oldestTileAt: null,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
@@ -124,7 +124,13 @@ class PlaybackClock {
|
||||
subscribe(listener) {
|
||||
if (this._destroyed || typeof listener !== 'function') return () => {};
|
||||
this._sceneClockListeners.add(listener);
|
||||
if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot });
|
||||
if (this._sceneClockSnapshot) {
|
||||
try {
|
||||
listener({ ...this._sceneClockSnapshot });
|
||||
} catch (error) {
|
||||
console.warn('[Scenes] Scene clock listener failed:', error);
|
||||
}
|
||||
}
|
||||
return () => this._sceneClockListeners.delete(listener);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a
|
||||
});
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => {
|
||||
const f = fixture();
|
||||
f.clock.publish(scene, shot, 1);
|
||||
let calls = 0;
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const unsubscribe = f.clock.subscribe(() => {
|
||||
calls++;
|
||||
throw new Error('listener failure');
|
||||
});
|
||||
assert.equal(typeof unsubscribe, 'function');
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:');
|
||||
f.clock.publish(scene, shot, 2);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(warnings.length, 2);
|
||||
unsubscribe();
|
||||
f.clock.publish(scene, shot, 3);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(warnings.length, 2);
|
||||
f.clock.destroy();
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
@@ -178,6 +178,92 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => {
|
||||
let executed = 0;
|
||||
let throwOnBusy = true;
|
||||
let throwOnEnd = true;
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const session = createInteractionSession({
|
||||
execute: () => {
|
||||
executed++;
|
||||
return true;
|
||||
},
|
||||
changed: (state) => {
|
||||
if (throwOnBusy && state.busy) throw new Error('start failed');
|
||||
if (throwOnEnd && !state.busy && state.selected === 'a') {
|
||||
throw new Error('end failed');
|
||||
}
|
||||
},
|
||||
});
|
||||
session.activate([{ id: 'a' }]);
|
||||
const result = await session.dispatch('a');
|
||||
assert.equal(result, true);
|
||||
assert.equal(executed, 1);
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: true,
|
||||
busy: false,
|
||||
selected: 'a',
|
||||
count: 1,
|
||||
});
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.equal(
|
||||
warnings[0][0],
|
||||
'[Scenes] Interaction session observer failed:',
|
||||
);
|
||||
assert.equal(
|
||||
warnings[1][0],
|
||||
'[Scenes] Interaction session observer failed:',
|
||||
);
|
||||
throwOnBusy = false;
|
||||
throwOnEnd = false;
|
||||
assert.equal(await session.dispatch('a'), true);
|
||||
assert.equal(executed, 2);
|
||||
session.clear();
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: false,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 0,
|
||||
});
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('an observer that always throws cannot stop activate or clear', () => {
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const session = createInteractionSession({
|
||||
execute: () => true,
|
||||
changed: () => {
|
||||
throw new Error('observer failed');
|
||||
},
|
||||
});
|
||||
session.activate([{ id: 'a' }, { id: 'b' }]);
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: true,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 2,
|
||||
});
|
||||
session.clear();
|
||||
assert.deepEqual(session.getState(), {
|
||||
active: false,
|
||||
busy: false,
|
||||
selected: null,
|
||||
count: 0,
|
||||
});
|
||||
assert.equal(warnings.length, 3);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
||||
const { SceneDirector } = await import('../../scenes/director.js');
|
||||
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
||||
@@ -188,7 +274,7 @@ test('settled pack shots cannot take the same-shot seek shortcut after Stop rele
|
||||
_claimCameraOwnership: () =>
|
||||
assert.fail('Must reload the released pack before camera seek'),
|
||||
});
|
||||
assert.equal(director._seekLoadedShot({ id: 'scene' }, { shot }), false);
|
||||
assert.equal(await director._seekLoadedShot({ id: 'scene' }, { shot }), null);
|
||||
});
|
||||
|
||||
test('actions preserve camera refusal, layer admission signal and explicit transition cap', async () => {
|
||||
|
||||
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
selected = null;
|
||||
let actions = new Map();
|
||||
const state = () => ({ active, busy, selected, count: actions.size });
|
||||
function notify() {
|
||||
try {
|
||||
changed(state());
|
||||
} catch (error) {
|
||||
console.warn('[Scenes] Interaction session observer failed:', error);
|
||||
}
|
||||
}
|
||||
function clear() {
|
||||
active = false;
|
||||
busy = false;
|
||||
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
actions.clear();
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
changed(state());
|
||||
notify();
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
clear();
|
||||
actions = new Map(items.map((item) => [item.id, item]));
|
||||
active = !!actions.size;
|
||||
changed(state());
|
||||
notify();
|
||||
},
|
||||
async dispatch(id) {
|
||||
const item = actions.get(id);
|
||||
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
controller = current;
|
||||
busy = true;
|
||||
selected = id;
|
||||
changed(state());
|
||||
notify();
|
||||
let abort;
|
||||
try {
|
||||
const cancelled = new Promise((resolve) => {
|
||||
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
||||
if (controller === current) {
|
||||
busy = false;
|
||||
controller = null;
|
||||
changed(state());
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export function buildPlaybackQueue(
|
||||
* releaseScene(scene, token?) leaves a previously loaded scene before playback
|
||||
* (false refuses that initial handoff). Between scenes and during final cleanup
|
||||
* it receives no cancelled token, so resource release can finish after Stop.
|
||||
* The first error wins when the final release also fails.
|
||||
* An empty/already-cancelled queue acquires and releases no resources.
|
||||
*
|
||||
* @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue.
|
||||
@@ -53,6 +54,7 @@ export async function playSceneQueue(
|
||||
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||
let activeScene = null;
|
||||
let completedShots = 0;
|
||||
let runFailed = false;
|
||||
try {
|
||||
if (!queue.length || cancelled()) {
|
||||
return {
|
||||
@@ -68,8 +70,9 @@ export async function playSceneQueue(
|
||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||
const { scene, shot } = queue[index];
|
||||
if (activeScene && activeScene.id !== scene.id) {
|
||||
await adapter.releaseScene(activeScene);
|
||||
const toRelease = activeScene;
|
||||
activeScene = null;
|
||||
await adapter.releaseScene(toRelease);
|
||||
if (cancelled()) break;
|
||||
}
|
||||
activeScene = scene;
|
||||
@@ -82,8 +85,23 @@ export async function playSceneQueue(
|
||||
}
|
||||
if (!cancelled()) await adapter.complete?.();
|
||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||
} catch (error) {
|
||||
runFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene);
|
||||
if (releaseOnFinish && activeScene) {
|
||||
const toRelease = activeScene;
|
||||
activeScene = null;
|
||||
try {
|
||||
await adapter.releaseScene(toRelease);
|
||||
} catch (error) {
|
||||
if (runFailed) {
|
||||
console.warn('[Scenes] Scene release failed:', error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,3 +196,68 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
|
||||
(error) => error === failure,
|
||||
);
|
||||
});
|
||||
|
||||
test('a phase failure is preserved when final scene release also throws', async () => {
|
||||
const phaseError = new Error('travel failed');
|
||||
const releaseError = new Error('release failed');
|
||||
const warnings = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
try {
|
||||
const f = fixture({
|
||||
travel: async () => {
|
||||
throw phaseError;
|
||||
},
|
||||
releaseScene: async () => {
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||
(error) => error === phaseError,
|
||||
);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0][0], '[Scenes] Scene release failed:');
|
||||
assert.equal(warnings[0][1], releaseError);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('a failing inter-scene release releases the active scene only once', async () => {
|
||||
const releaseError = new Error('release failed');
|
||||
const released = [];
|
||||
const f = fixture({
|
||||
releaseScene: async (scene) => {
|
||||
released.push(scene.id);
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a'), f),
|
||||
(error) => error === releaseError,
|
||||
);
|
||||
assert.deepEqual(released, ['a']);
|
||||
});
|
||||
|
||||
test('a falsy phase failure is still preserved over a release failure', async () => {
|
||||
const releaseError = new Error('release failed');
|
||||
const origWarn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const f = fixture({
|
||||
travel: async () => {
|
||||
throw undefined;
|
||||
},
|
||||
releaseScene: async () => {
|
||||
throw releaseError;
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f),
|
||||
(error) => error === undefined,
|
||||
);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
+52
-13
@@ -39,6 +39,7 @@ import {
|
||||
} from './hudLayouts.js';
|
||||
import { formatNumber, msg, t } from './i18n/index.js';
|
||||
import { localizeLayerName } from './ui/layerPanel.js';
|
||||
import { isEmbeddedInline } from './app/embed.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -202,7 +203,13 @@ export class IntelHUD {
|
||||
this._dataManagerUnsubscribe = null;
|
||||
this._summaryDirty = true;
|
||||
this._summaryRequest = null;
|
||||
this._summaryUnconfigured = false;
|
||||
// The last place lookup and the view it answered for (see
|
||||
// getBasemapLabelContext), reused until the view changes enough.
|
||||
this._placeLabels = null;
|
||||
this._lastSummarySignature = '';
|
||||
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
|
||||
this._lastSummaryText = null;
|
||||
this._summaryRevision = 0;
|
||||
// One-shot guards so the very first summary lands immediately instead of
|
||||
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
|
||||
@@ -363,9 +370,11 @@ export class IntelHUD {
|
||||
dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden';
|
||||
}, 800);
|
||||
|
||||
// Camera-derived data — 4 updates/second (250ms)
|
||||
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
|
||||
// page, except an inline panel, whose host may report it hidden while it
|
||||
// is on screen (see keepPanelRendering in app/embed.js).
|
||||
this._updateInterval = setInterval(() => {
|
||||
if (!this._visible) return;
|
||||
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
|
||||
this._updateCameraData();
|
||||
}, 250);
|
||||
|
||||
@@ -826,6 +835,15 @@ export class IntelHUD {
|
||||
if (this.summaryPolicy.canRequest?.() === false) return;
|
||||
|
||||
const revision = this._summaryRevision;
|
||||
// The place lookups below are paid Google requests made only to feed the
|
||||
// AI summary, so a server without one must not be asked to make them.
|
||||
if (!(await this._summaryAvailable())) {
|
||||
if (revision !== this._summaryRevision) return;
|
||||
this._summaryDirty = false;
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
return;
|
||||
}
|
||||
if (revision !== this._summaryRevision) return;
|
||||
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
|
||||
// owns the returned promise — a rejection escaping from here lands as an
|
||||
// unhandled rejection in the console. Building the context walks the live
|
||||
@@ -848,6 +866,9 @@ export class IntelHUD {
|
||||
const signature = JSON.stringify(context);
|
||||
if (!force && signature === this._lastSummarySignature) {
|
||||
this._summaryDirty = false;
|
||||
// A settle painted the fallback line over the reply this context
|
||||
// already has; reused place labels make that common, so restore it.
|
||||
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
|
||||
return;
|
||||
}
|
||||
if (this._summaryRequest) return;
|
||||
@@ -855,6 +876,7 @@ export class IntelHUD {
|
||||
if (force) this._setSummaryText(fallbackText, false);
|
||||
this._summaryDirty = false;
|
||||
this._lastSummarySignature = signature;
|
||||
this._lastSummaryText = null;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -870,18 +892,16 @@ export class IntelHUD {
|
||||
const data = response.data;
|
||||
if (revision !== this._summaryRevision) return;
|
||||
if (isHudSummaryUnconfigured(response.status, data)) {
|
||||
this._summaryUnconfigured = true;
|
||||
this._setSummaryText(fallbackText, animate);
|
||||
return;
|
||||
}
|
||||
if (!response.ok || !data?.summary) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
this._setSummaryText(
|
||||
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
|
||||
? data.summary
|
||||
: fallbackText,
|
||||
animate,
|
||||
);
|
||||
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
|
||||
this._lastSummaryText = data.summary;
|
||||
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
|
||||
} catch (error) {
|
||||
// The 5 s timeout is a failure like any other; a move's abort (or
|
||||
// destroy's) is not.
|
||||
@@ -915,12 +935,31 @@ export class IntelHUD {
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* False once the summary service reports no AI summary: by its capability
|
||||
* probe, when it has one, or by a deliberate unconfigured reply. Unknown
|
||||
* stays available, as before.
|
||||
*/
|
||||
async _summaryAvailable() {
|
||||
if (this._summaryUnconfigured) return false;
|
||||
try {
|
||||
if ((await this.summaryService?.configured?.()) === false)
|
||||
this._summaryUnconfigured = true;
|
||||
} catch {
|
||||
/* an unanswered probe leaves the summary as it was */
|
||||
}
|
||||
return !this._summaryUnconfigured;
|
||||
}
|
||||
|
||||
async _summaryContext() {
|
||||
const labels = await getBasemapLabelContext(
|
||||
this.viewer,
|
||||
this.placeSearch,
|
||||
this.basemapContext,
|
||||
);
|
||||
const revision = this._summaryRevision;
|
||||
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
|
||||
...this.basemapContext,
|
||||
previous: this._placeLabels,
|
||||
});
|
||||
// A slower build for a view the camera has left must not replace the
|
||||
// newer view's lookup.
|
||||
if (revision === this._summaryRevision) this._placeLabels = labels;
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// The HUD's 250 ms telemetry readout skips ticks in a hidden page, except in
|
||||
// an inline panel, whose host may report it hidden while it is on screen.
|
||||
//
|
||||
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
|
||||
// from, so a module hook swaps it for a stub before the import (the same
|
||||
// setup as hudAltitudeDatum.test.mjs).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (url === MGRS_STUB_URL) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||
};
|
||||
}
|
||||
return next(url, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { IntelHUD } = await import('./hud.js');
|
||||
|
||||
function installHiddenPage() {
|
||||
const elements = new Map(
|
||||
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
|
||||
.map((id) => [id, { textContent: '' }]),
|
||||
);
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {
|
||||
hidden: true,
|
||||
visibilityState: 'hidden',
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
pitch: -Math.PI / 2,
|
||||
positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 },
|
||||
computeViewRectangle: () => undefined,
|
||||
moveStart: { addEventListener() {}, removeEventListener() {} },
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
};
|
||||
return {
|
||||
alt: () => elements.get('hud-alt').textContent,
|
||||
viewer,
|
||||
restore() {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the HUD readout pauses in a hidden page but keeps updating in an inline panel', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const page = installHiddenPage();
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(page.viewer);
|
||||
hud._visible = true;
|
||||
|
||||
t.mock.timers.tick(250);
|
||||
assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick');
|
||||
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
t.mock.timers.tick(250);
|
||||
assert.match(
|
||||
page.alt(),
|
||||
/^ALT: 500m/,
|
||||
'an inline panel reported hidden keeps its readout updating',
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
hud?.destroy();
|
||||
page.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { greatCircleMeters } from './geoDistance.js';
|
||||
|
||||
/**
|
||||
* When the HUD's passive place lookup may spend another reverse geocode or
|
||||
* Nearby Places request. Every request is a paid Google call, and the HUD
|
||||
* asks on its own whenever the camera settles, so a new view only earns a
|
||||
* new lookup once it shows a meaningfully different area: the center has
|
||||
* moved by a quarter of the visible width, or the camera's distance to the
|
||||
* center has doubled or halved. The width is measured at the view center
|
||||
* from that distance, so terrain under the camera and a tilted view scale it
|
||||
* as they scale what is on screen.
|
||||
*/
|
||||
const MOVE_FRACTION = 0.25;
|
||||
const RANGE_RATIO = 2;
|
||||
/** Street-level views still need a small move before they count as new. */
|
||||
const MIN_SPAN_M = 100;
|
||||
const DEFAULT_FOV_RAD = Math.PI / 3;
|
||||
|
||||
/**
|
||||
* Describe the view a lookup answers for.
|
||||
* @param {{latitude: number, longitude: number, rangeM: number, fovRad?: number, aspectRatio?: number, tier?: string}} view
|
||||
* `rangeM` is the camera's distance to the view center. `fovRad` and
|
||||
* `aspectRatio` are the Cesium perspective frustum's. `tier` names which
|
||||
* lookups the camera height calls for; a view in another tier is new.
|
||||
* @returns {{latitude: number, longitude: number, rangeM: number, spanM: number, tier: string}}
|
||||
*/
|
||||
export function placeLookupView({
|
||||
latitude,
|
||||
longitude,
|
||||
rangeM,
|
||||
fovRad,
|
||||
aspectRatio,
|
||||
tier = '',
|
||||
}) {
|
||||
const fov =
|
||||
Number.isFinite(fovRad) && fovRad > 0 && fovRad < Math.PI
|
||||
? fovRad
|
||||
: DEFAULT_FOV_RAD;
|
||||
// Cesium's fov spans the wider screen dimension, so a portrait screen's
|
||||
// width is narrower than it.
|
||||
const aspect = Number(aspectRatio);
|
||||
const horizontalFov =
|
||||
Number.isFinite(aspect) && aspect > 0 && aspect < 1
|
||||
? 2 * Math.atan(Math.tan(fov / 2) * aspect)
|
||||
: fov;
|
||||
const range = Math.max(1, Number(rangeM) || 0);
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
rangeM: range,
|
||||
spanM: Math.max(MIN_SPAN_M, 2 * range * Math.tan(horizontalFov / 2)),
|
||||
tier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `next` shows enough new ground to look its places up again.
|
||||
* A missing or unusable previous view always does.
|
||||
*/
|
||||
export function placeLookupViewChanged(previous, next) {
|
||||
if (!previous || !next || previous.tier !== next.tier) return true;
|
||||
const ratio =
|
||||
Math.max(previous.rangeM, next.rangeM) /
|
||||
Math.min(previous.rangeM, next.rangeM);
|
||||
if (!Number.isFinite(ratio) || ratio >= RANGE_RATIO) return true;
|
||||
const moved = greatCircleMeters(
|
||||
{ lat: previous.latitude, lon: previous.longitude },
|
||||
{ lat: next.latitude, lon: next.longitude },
|
||||
);
|
||||
return !(moved < MOVE_FRACTION * previous.spanM);
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport samples the HUD geocodes besides the view center: one more
|
||||
* point. Sample 0 is the screen center, so it stands in for the center only
|
||||
* when the center itself is not geocoded at this height.
|
||||
*/
|
||||
export function hudViewportSamples(samples, centerGeocoded) {
|
||||
return centerGeocoded ? samples.slice(1, 2) : samples.slice(0, 2);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// The HUD's place lookups are paid Google requests made only to feed the AI
|
||||
// summary, so a server without one must not be asked to make them.
|
||||
//
|
||||
// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports
|
||||
// from, so a module hook swaps it for a stub before the import (the same
|
||||
// setup as hudPageVisibility.test.mjs).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
},
|
||||
load(url, context, next) {
|
||||
if (url === MGRS_STUB_URL) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source:
|
||||
'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n',
|
||||
};
|
||||
}
|
||||
return next(url, context);
|
||||
},
|
||||
});
|
||||
|
||||
const { IntelHUD } = await import('./hud.js');
|
||||
|
||||
function installPage() {
|
||||
const elements = new Map(
|
||||
[
|
||||
'hud-alt',
|
||||
'hud-summary',
|
||||
'hud-mgrs',
|
||||
'hud-latlon',
|
||||
'hud-bottom-line',
|
||||
'hud-gsd',
|
||||
'hud-coll',
|
||||
'hud-ona',
|
||||
'hud-mode',
|
||||
].map((id) => [id, { textContent: '' }]),
|
||||
);
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
globalThis.document = {
|
||||
hidden: false,
|
||||
visibilityState: 'visible',
|
||||
getElementById: (id) => elements.get(id) ?? null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
globalThis.window ??= {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
};
|
||||
const viewer = {
|
||||
camera: {
|
||||
pitch: -Math.PI / 2,
|
||||
positionCartographic: { latitude: 0.53, longitude: -1.7, height: 600 },
|
||||
computeViewRectangle: () => undefined,
|
||||
moveStart: { addEventListener() {}, removeEventListener() {} },
|
||||
moveEnd: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
};
|
||||
return {
|
||||
viewer,
|
||||
restore() {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete globalThis[key];
|
||||
else globalThis[key] = value;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A HUD whose place lookup and summary service only count their calls. */
|
||||
function createHud(t, summaryService) {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const page = installPage();
|
||||
const hud = new IntelHUD(page.viewer, { summaryService });
|
||||
t.after(() => {
|
||||
hud.destroy();
|
||||
page.restore();
|
||||
});
|
||||
const calls = { context: 0 };
|
||||
hud._latestMetrics = { altM: 600 };
|
||||
hud._summaryContext = async () => {
|
||||
calls.context += 1;
|
||||
return { placeLabels: ['Austin'], streetLabels: [], nearbyPlaceLabels: [] };
|
||||
};
|
||||
return { hud, calls };
|
||||
}
|
||||
|
||||
test('a server that reports no AI summary gets no place lookups', async (t) => {
|
||||
const service = {
|
||||
calls: 0,
|
||||
configured: async () => false,
|
||||
async summarize() {
|
||||
this.calls += 1;
|
||||
return { ok: true, status: 200, data: { summary: 'never' } };
|
||||
},
|
||||
};
|
||||
const { hud, calls } = createHud(t, service);
|
||||
await hud._updateSummary(false, true);
|
||||
hud._markSummaryDirty();
|
||||
await hud._updateSummary(false);
|
||||
assert.equal(calls.context, 0);
|
||||
assert.equal(service.calls, 0);
|
||||
});
|
||||
|
||||
test('an unconfigured reply stops the lookups for the rest of the page', async (t) => {
|
||||
const service = {
|
||||
calls: 0,
|
||||
async summarize() {
|
||||
this.calls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
configured: false,
|
||||
code: 'OPENAI_NOT_CONFIGURED',
|
||||
error: null,
|
||||
summary: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const { hud, calls } = createHud(t, service);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(calls.context, 1, 'a server without the probe is asked once');
|
||||
hud._markSummaryDirty();
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(calls.context, 1);
|
||||
assert.equal(service.calls, 1);
|
||||
});
|
||||
|
||||
test('a configured or unknown summary keeps looking places up', async (t) => {
|
||||
for (const configured of [true, null, undefined]) {
|
||||
await t.test(String(configured), async (st) => {
|
||||
const service = {
|
||||
configured:
|
||||
configured === undefined ? undefined : async () => configured,
|
||||
summarize: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Austin capitol district live' },
|
||||
}),
|
||||
};
|
||||
const { hud, calls } = createHud(st, service);
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(calls.context, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('an unchanged context restores its accepted reply over the fallback line', async (t) => {
|
||||
const service = {
|
||||
calls: 0,
|
||||
configured: async () => true,
|
||||
async summarize() {
|
||||
this.calls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Austin capitol district live' },
|
||||
};
|
||||
},
|
||||
};
|
||||
const { hud } = createHud(t, service);
|
||||
const shown = () =>
|
||||
globalThis.document.getElementById('hud-summary').textContent;
|
||||
await hud._updateSummary(false, true);
|
||||
assert.equal(shown(), 'Austin capitol district live');
|
||||
|
||||
// A small pan settles: the fallback line is painted and the reused labels
|
||||
// give the same context, so no new request is made.
|
||||
hud._setSummaryText('fallback line', false);
|
||||
hud._markSummaryDirty();
|
||||
await hud._updateSummary(false);
|
||||
assert.equal(service.calls, 1);
|
||||
assert.equal(shown(), 'Austin capitol district live');
|
||||
});
|
||||
@@ -864,6 +864,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.",
|
||||
@@ -3001,5 +3002,7 @@
|
||||
"Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.",
|
||||
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
|
||||
"Preview is off.": "La vista previa está desactivada.",
|
||||
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz."
|
||||
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas"
|
||||
}
|
||||
@@ -254,6 +254,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%",
|
||||
@@ -3070,5 +3071,7 @@
|
||||
"Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.",
|
||||
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
|
||||
"Preview is off.": "Предпросмотр отключён.",
|
||||
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения."
|
||||
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов"
|
||||
}
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%",
|
||||
@@ -2863,5 +2864,7 @@
|
||||
"{count} placemarks": {
|
||||
"other": "{count} 个地标"
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。"
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
|
||||
"route proxy error": "路线代理出错"
|
||||
}
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路",
|
||||
"LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%",
|
||||
"SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%",
|
||||
"STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片",
|
||||
"Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬",
|
||||
"LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%",
|
||||
@@ -2863,5 +2864,7 @@
|
||||
"{count} placemarks": {
|
||||
"other": "{count} 個地標"
|
||||
},
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。"
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
|
||||
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
|
||||
"route proxy error": "路線代理出錯"
|
||||
}
|
||||
+81
-1
@@ -281,6 +281,67 @@ export function selectPhotonFeature(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Short country names people type that Photon's fields don't carry. */
|
||||
const COUNTRY_ALIASES = Object.freeze({ US: 'usa', GB: 'uk' });
|
||||
|
||||
/**
|
||||
* OSM keys of settlements and administrative areas. Among candidates that fit
|
||||
* a "place, region" query, these are what the region names a place in: a cafe
|
||||
* called "Paris" in Lyon is in France too, but "Paris, France" means the city.
|
||||
*/
|
||||
const SETTLEMENT_OSM_KEYS = Object.freeze(new Set(['place', 'boundary']));
|
||||
|
||||
/** The region part of a "place, region" query, normalized ('' if none). */
|
||||
function queryRegion(query) {
|
||||
const [, ...rest] = String(query ?? '').split(',');
|
||||
return normalizeToponym(rest.join(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* For a "place, region" query, pick the candidate named for the place that
|
||||
* actually lies in the region. "Paris, Texas" is also the name of a pub in
|
||||
* Budapest: matched as one name it beat the town, so the region has to be
|
||||
* checked against where each candidate is, not against its name. When several
|
||||
* candidates fit, a settlement or administrative area wins over a POI.
|
||||
* @param {object[]} features - Photon features, in the order returned.
|
||||
* @param {string} query - The query as typed.
|
||||
* @returns {?object} null when the query names no region or nothing fits.
|
||||
*/
|
||||
export function selectQualifiedFeature(features, query) {
|
||||
const [headText] = String(query ?? '').split(',');
|
||||
const region = queryRegion(query);
|
||||
const head = normalizeToponym(headText);
|
||||
if (!head || !region) return null;
|
||||
const words = region.split(' ');
|
||||
const qualified = (Array.isArray(features) ? features : []).filter(
|
||||
(feature) => {
|
||||
const properties = feature?.properties ?? {};
|
||||
const name = normalizeToponym(properties.name);
|
||||
// Exactly the place: "Paris Gare de Lyon" is in France too, but it
|
||||
// is not what "Paris, France" asks for.
|
||||
if (name !== head) return false;
|
||||
const where = ` ${normalizeToponym(
|
||||
[
|
||||
properties.city,
|
||||
properties.county,
|
||||
properties.state,
|
||||
properties.country,
|
||||
properties.countrycode,
|
||||
COUNTRY_ALIASES[String(properties.countrycode ?? '').toUpperCase()],
|
||||
].join(' '),
|
||||
)} `;
|
||||
return words.every((word) => where.includes(` ${word} `));
|
||||
},
|
||||
);
|
||||
return (
|
||||
qualified.find((feature) =>
|
||||
SETTLEMENT_OSM_KEYS.has(feature?.properties?.osm_key),
|
||||
) ??
|
||||
qualified[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Evict the oldest memo entries until the cache is within its cap. */
|
||||
function trimPhotonCache(cache) {
|
||||
while (cache.size > PHOTON_CACHE_MAX) {
|
||||
@@ -359,12 +420,28 @@ export async function geocodeKeylessWithOutcome(
|
||||
// to the wrong continent. So a biased answer is only accepted when it leads
|
||||
// with the name asked for; otherwise the same query runs unbiased, which is
|
||||
// what a user searching for somewhere off-screen meant in the first place.
|
||||
// A query naming a region is answered nearby only by a match inside it.
|
||||
// A nearby place whose own name has a comma ("Bed, Bath & Beyond") and
|
||||
// equals the whole query is kept as the last choice, after the region
|
||||
// checks of both passes.
|
||||
let feature = null;
|
||||
let nearbyNameMatch = null;
|
||||
let everyAskAnswered = true;
|
||||
if (bias) {
|
||||
const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint }));
|
||||
if (biased === null) everyAskAnswered = false;
|
||||
else feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
|
||||
else if (queryRegion(trimmed)) {
|
||||
feature = selectQualifiedFeature(biased, trimmed);
|
||||
// Exactly the whole query: "Springfield IL Auto" only starts with it.
|
||||
const whole = normalizeToponym(trimmed);
|
||||
nearbyNameMatch =
|
||||
biased.find(
|
||||
(candidate) =>
|
||||
normalizeToponym(candidate?.properties?.name) === whole,
|
||||
) ?? null;
|
||||
} else {
|
||||
feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
@@ -379,9 +456,12 @@ export async function geocodeKeylessWithOutcome(
|
||||
// the thing being searched for; the tail only says where to look.
|
||||
const head = normalizeToponym(trimmed.split(',')[0]);
|
||||
feature =
|
||||
selectQualifiedFeature(anywhere, trimmed) ||
|
||||
nearbyNameMatch ||
|
||||
selectPhotonFeature(anywhere, head, { allowContains: true }) ||
|
||||
anywhere[0];
|
||||
}
|
||||
if (!feature) feature = nearbyNameMatch;
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
photonResultTypes,
|
||||
photonSearchUrl,
|
||||
selectPhotonFeature,
|
||||
selectQualifiedFeature,
|
||||
} from './keylessGeocoder.js';
|
||||
import { geocodeNavigationMode } from './locations.js';
|
||||
|
||||
@@ -288,6 +289,245 @@ test('a "place, region" query is matched on the place, not the region', async ()
|
||||
assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`);
|
||||
});
|
||||
|
||||
/** Photon's real answers for "Paris, Texas" biased to Denver, then unbiased. */
|
||||
const PARIS_TX = {
|
||||
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
|
||||
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Paris', state: 'Texas', country: 'United States', countrycode: 'US' },
|
||||
};
|
||||
const PARIS_TEXAS_PUB = {
|
||||
geometry: { type: 'Point', coordinates: [19.0606, 47.4868] },
|
||||
properties: { osm_key: 'amenity', osm_value: 'pub', type: 'house', name: 'Paris, Texas', city: 'Budapest', country: 'Magyarország', countrycode: 'HU' },
|
||||
};
|
||||
const PARIS_TEXAS_SALON = {
|
||||
geometry: { type: 'Point', coordinates: [153.03, -27.47] },
|
||||
properties: { osm_key: 'shop', osm_value: 'hairdresser', type: 'house', name: 'Paris Texas', city: 'Brisbane', state: 'Queensland', country: 'Australia', countrycode: 'AU' },
|
||||
};
|
||||
|
||||
test('"Paris, Texas" is the town in Texas, not a pub of that name in Budapest', async () => {
|
||||
// Measured from Denver against the live service: the biased answer listed the
|
||||
// town first, but the pub's name equals the whole query and won, flying the
|
||||
// camera to Budapest at rooftop range.
|
||||
const fetchImpl = photonPair([PARIS_TX, PARIS_TEXAS_PUB, PARIS_TEXAS_SALON], [PARIS_TX]);
|
||||
|
||||
const result = await geocodeKeyless('Paris, Texas', { bias: '39.70,-104.90|39.80,-104.80', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('a region is checked against where a candidate is, not its name', () => {
|
||||
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TEXAS_SALON, PARIS_TX];
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas'), PARIS_TX);
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, United States'), PARIS_TX);
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, US'), PARIS_TX);
|
||||
// No region, or a region nothing lies in, leaves the choice to the name rules.
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris'), null);
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Ontario'), null);
|
||||
assert.equal(selectQualifiedFeature(undefined, 'Paris, Texas'), null);
|
||||
});
|
||||
|
||||
test('a region nearby fits nothing, so the unbiased answer decides', async () => {
|
||||
const fetchImpl = photonPair([PARIS_TEXAS_PUB], [PARIS_TX, PARIS_TEXAS_PUB]);
|
||||
|
||||
const result = await geocodeKeyless('Paris, Texas', { bias: '47.40,19.00|47.50,19.10', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 2);
|
||||
});
|
||||
|
||||
test('"USA" and "UK" name their countries, and a trailing comma keeps the nearby answer', async () => {
|
||||
const pubFirst = [PARIS_TEXAS_PUB, PARIS_TX];
|
||||
assert.equal(selectQualifiedFeature(pubFirst, 'Paris, Texas, USA'), PARIS_TX);
|
||||
// "Paris," names no region: the camera-biased pass still answers.
|
||||
const fetchImpl = photonPair([PARIS_TX], []);
|
||||
const result = await geocodeKeyless('Paris,', {
|
||||
bias: '33.60,-95.60|33.70,-95.50',
|
||||
fetchImpl,
|
||||
});
|
||||
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('a region match takes the place itself, not a place whose name starts with it', () => {
|
||||
const station = {
|
||||
geometry: { type: 'Point', coordinates: [4.86, 45.76] },
|
||||
properties: {
|
||||
osm_key: 'railway',
|
||||
name: 'Paris Gare de Lyon',
|
||||
city: 'Paris',
|
||||
country: 'France',
|
||||
countrycode: 'FR',
|
||||
},
|
||||
};
|
||||
const paris = {
|
||||
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
|
||||
properties: {
|
||||
osm_key: 'place',
|
||||
name: 'Paris',
|
||||
state: 'Île-de-France',
|
||||
country: 'France',
|
||||
countrycode: 'FR',
|
||||
},
|
||||
};
|
||||
assert.equal(selectQualifiedFeature([station, paris], 'Paris, France'), paris);
|
||||
assert.equal(selectQualifiedFeature([station], 'Paris, France'), null);
|
||||
});
|
||||
|
||||
test('"UK" names the United Kingdom', () => {
|
||||
const london = {
|
||||
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
|
||||
properties: {
|
||||
osm_key: 'place',
|
||||
name: 'London',
|
||||
country: 'United Kingdom',
|
||||
countrycode: 'GB',
|
||||
},
|
||||
};
|
||||
const ontario = {
|
||||
geometry: { type: 'Point', coordinates: [-81.25, 42.98] },
|
||||
properties: {
|
||||
osm_key: 'place',
|
||||
name: 'London',
|
||||
state: 'Ontario',
|
||||
country: 'Canada',
|
||||
countrycode: 'CA',
|
||||
},
|
||||
};
|
||||
assert.equal(selectQualifiedFeature([ontario, london], 'London, UK'), london);
|
||||
});
|
||||
|
||||
test('a nearby place whose own name has a comma still wins when nothing matches as place and region', async () => {
|
||||
const store = (lon, lat, city) => ({
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: {
|
||||
osm_key: 'shop',
|
||||
osm_value: 'houseware',
|
||||
name: 'Bed, Bath & Beyond',
|
||||
city,
|
||||
countrycode: 'US',
|
||||
},
|
||||
});
|
||||
const nearby = store(-104.94, 39.66, 'Denver');
|
||||
const fetchImpl = photonPair([nearby], [store(-73.99, 40.74, 'New York')]);
|
||||
|
||||
const result = await geocodeKeyless('Bed, Bath & Beyond', {
|
||||
bias: '39.60,-105.00|39.70,-104.90',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.match(result.label, /Denver/);
|
||||
// Main answered this from the nearby pass alone. The second request is the
|
||||
// accepted cost of checking the region unbiased first.
|
||||
assert.equal(fetchImpl.urls.length, 2, 'the region checks ran first');
|
||||
});
|
||||
|
||||
test('the nearby whole-name fallback takes an exact name, not one that only starts with the query', async () => {
|
||||
const place = (name, lon, lat, props) => ({
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: { osm_key: 'place', name, ...props },
|
||||
});
|
||||
const nearby = [
|
||||
place('Springfield IL Auto', -89.6, 39.8, {
|
||||
osm_key: 'shop',
|
||||
city: 'Springfield',
|
||||
state: 'Illinois',
|
||||
countrycode: 'US',
|
||||
}),
|
||||
];
|
||||
const anywhere = [
|
||||
place('Springfield', -89.65, 39.78, { state: 'Illinois', countrycode: 'US' }),
|
||||
];
|
||||
const fetchImpl = photonPair(nearby, anywhere);
|
||||
|
||||
const result = await geocodeKeyless('Springfield, IL', {
|
||||
bias: '39.70,-89.70|39.90,-89.50',
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
assert.doesNotMatch(result.label, /Auto/);
|
||||
assert.match(result.label, /^Springfield/);
|
||||
});
|
||||
|
||||
test('a region query without a camera bias still takes the place in the region', async () => {
|
||||
// Unbiased, Photon can list the pub first; main took it because its name
|
||||
// starts with "Paris".
|
||||
const fetchImpl = photonPair([], [PARIS_TEXAS_PUB, PARIS_TX]);
|
||||
|
||||
const result = await geocodeKeyless('Paris, Texas', { fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 34, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
});
|
||||
|
||||
test('a region is matched by whole words, and every word has to fit', () => {
|
||||
const londonUk = {
|
||||
geometry: { type: 'Point', coordinates: [-0.13, 51.51] },
|
||||
properties: { osm_key: 'place', name: 'London', state: 'England', country: 'United Kingdom', countrycode: 'GB' },
|
||||
};
|
||||
// "on" is inside "london" and "england", but it is not a word of either.
|
||||
assert.equal(selectQualifiedFeature([londonUk], 'London, ON'), null);
|
||||
// "Texas" fits, "Ontario" does not, so the town does not qualify.
|
||||
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Texas, Ontario'), null);
|
||||
assert.equal(selectQualifiedFeature([PARIS_TX], 'Paris, Tex'), null);
|
||||
});
|
||||
|
||||
test('a region can be the city or the county a candidate lies in', () => {
|
||||
const street = {
|
||||
geometry: { type: 'Point', coordinates: [-105.27, 40.02] },
|
||||
properties: { osm_key: 'highway', name: 'Main Street', city: 'Boulder', countrycode: 'US' },
|
||||
};
|
||||
const town = {
|
||||
geometry: { type: 'Point', coordinates: [-95.5555, 33.6609] },
|
||||
properties: { osm_key: 'place', name: 'Paris', county: 'Lamar', countrycode: 'US' },
|
||||
};
|
||||
assert.equal(selectQualifiedFeature([street], 'Main Street, Boulder'), street);
|
||||
assert.equal(selectQualifiedFeature([town], 'Paris, Lamar'), town);
|
||||
});
|
||||
|
||||
test('when a POI and the place both fit the region, the place wins', async () => {
|
||||
// A cafe named "Paris" in Lyon is in France too. Nearby it can rank above the
|
||||
// city, and it would frame at rooftop range.
|
||||
const cafe = {
|
||||
geometry: { type: 'Point', coordinates: [4.84, 45.76] },
|
||||
properties: { osm_key: 'amenity', osm_value: 'cafe', name: 'Paris', city: 'Lyon', country: 'France', countrycode: 'FR' },
|
||||
};
|
||||
const paris = {
|
||||
geometry: { type: 'Point', coordinates: [2.35, 48.86] },
|
||||
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'Paris', state: 'Île-de-France', country: 'France', countrycode: 'FR' },
|
||||
};
|
||||
const fetchImpl = photonPair([cafe, paris], [paris]);
|
||||
|
||||
const result = await geocodeKeyless('Paris, France', { bias: '45.70,4.80|45.80,4.90', fetchImpl });
|
||||
|
||||
assert.equal(Math.round(result.lat), 49, `got ${result.label}`);
|
||||
assert.equal(fetchImpl.urls.length, 1);
|
||||
// A POI alone still qualifies: "Main Street, Denver" names a street.
|
||||
assert.equal(selectQualifiedFeature([cafe], 'Paris, France'), cafe);
|
||||
});
|
||||
|
||||
test('if the unbiased request fails, the nearby whole-name match is still the answer', async () => {
|
||||
const store = {
|
||||
geometry: { type: 'Point', coordinates: [-104.94, 39.66] },
|
||||
properties: { osm_key: 'shop', name: 'Bed, Bath & Beyond', city: 'Denver', countrycode: 'US' },
|
||||
};
|
||||
const urls = [];
|
||||
const fetchImpl = (url) => {
|
||||
urls.push(url);
|
||||
if (!new URL(url).searchParams.has('lat')) return Promise.resolve({ ok: false, status: 503 });
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [store] }) });
|
||||
};
|
||||
|
||||
const outcome = await geocodeKeylessWithOutcome('Bed, Bath & Beyond', {
|
||||
bias: '39.60,-105.00|39.70,-104.90',
|
||||
fetchImpl,
|
||||
cache: new Map(),
|
||||
});
|
||||
|
||||
assert.match(outcome.place.label, /Denver/);
|
||||
assert.equal(outcome.answered, true);
|
||||
assert.equal(urls.length, 2);
|
||||
});
|
||||
|
||||
test('accents and punctuation do not change what a name compares as', () => {
|
||||
assert.equal(normalizeToponym('Huế'), 'hue');
|
||||
assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay');
|
||||
@@ -453,7 +693,6 @@ function readGeocodingConsumer(file) {
|
||||
return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n');
|
||||
}
|
||||
|
||||
|
||||
test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => {
|
||||
for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) {
|
||||
const params = new URL(photonSearchUrl('island', { bias })).searchParams;
|
||||
|
||||
@@ -42,33 +42,21 @@ export const DIRECTIONS_MODES = Object.freeze({
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
/**
|
||||
* The route proxy's refusal texts (server/providers/places/routes.js). They
|
||||
* stay English on the wire and in the AI tools; the row shows them translated.
|
||||
* The row error for a failed route request (createDirectionsSource errors),
|
||||
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
|
||||
* in src/ui/statusText.js).
|
||||
*/
|
||||
const ROUTING_REFUSALS = new Set([
|
||||
msg('rate limited'),
|
||||
msg('routing service is rate limited'),
|
||||
msg('route leg too long'),
|
||||
msg('route too long'),
|
||||
]);
|
||||
|
||||
/** A refusal text from the route proxy in the language; unknown ones as given. */
|
||||
export function presentRoutingRefusal(text) {
|
||||
return ROUTING_REFUSALS.has(text) ? t(text) : text;
|
||||
}
|
||||
|
||||
/** The row text for a failed route request (createDirectionsSource errors). */
|
||||
export function presentRoutingFailure(error) {
|
||||
if (error?.status === 429) {
|
||||
return error.refusal
|
||||
? t('{error} — try again in a moment', {
|
||||
error: presentRoutingRefusal(error.refusal),
|
||||
})
|
||||
: t('Routing is rate limited — try again in a moment');
|
||||
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
`${error.refusal} — try again in a moment`
|
||||
: msg('Routing is rate limited — try again in a moment');
|
||||
}
|
||||
if (Number.isFinite(error?.status) && error.status > 0)
|
||||
return t('Routing unavailable (HTTP {status})', { status: error.status });
|
||||
return error?.message || t('Routing unavailable');
|
||||
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
return `Routing unavailable (HTTP ${error.status})`;
|
||||
return error?.message || msg('Routing unavailable');
|
||||
}
|
||||
|
||||
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
|
||||
@@ -665,7 +653,9 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: route?.steps.length || 0,
|
||||
lastUpdate,
|
||||
error: t('Another map tool is using clicks — close it, then SET A again'),
|
||||
error: msg(
|
||||
'Another map tool is using clicks — close it, then SET A again',
|
||||
),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -684,7 +674,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || t('No route found'),
|
||||
error: error || msg('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -1166,13 +1156,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
clearRouteGraphics();
|
||||
_status = 'error';
|
||||
_error =
|
||||
payload?.error === 'no route found'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal(payload.error),
|
||||
})
|
||||
: t('No route found between A and B');
|
||||
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
payload?.error && payload.error !== 'no route found'
|
||||
? `Routing failed: ${payload.error}`
|
||||
: msg('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
_route = route;
|
||||
@@ -1189,9 +1176,9 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// happened instead.
|
||||
_error =
|
||||
error?.name === 'AbortError'
|
||||
? t('Routing timed out')
|
||||
? msg('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
? msg('Routing unavailable — no response from the routing service')
|
||||
: presentRoutingFailure(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Directions reports its fixed errors as English data (getStats(), and so the
|
||||
// layer snapshot the voice tools and the analyst read); the Layers panel row
|
||||
// shows them in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t as translate } from '../../i18n/index.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { directionsStats, presentRoutingFailure } from './index.js';
|
||||
|
||||
const idle = {
|
||||
enabled: true,
|
||||
mode: 'car',
|
||||
armed: null,
|
||||
a: null,
|
||||
b: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
route: null,
|
||||
lastUpdate: null,
|
||||
};
|
||||
|
||||
const cases = [
|
||||
[
|
||||
{ ...idle, pointerBlocked: true },
|
||||
'Another map tool is using clicks — close it, then SET A again',
|
||||
],
|
||||
[{ ...idle, status: 'error' }, 'No route found'],
|
||||
[
|
||||
{ ...idle, status: 'error', error: 'Routing timed out' },
|
||||
'Routing timed out',
|
||||
],
|
||||
];
|
||||
|
||||
function rowFor(stats) {
|
||||
return LayerPanel.prototype._buildMetaText({
|
||||
id: 'directions',
|
||||
enabled: true,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
// Route request failures with the proxy's refusal or an HTTP status in them:
|
||||
// [the request error, its English row error, the row text in the language]
|
||||
const failures = () => [
|
||||
[
|
||||
{ status: 429, refusal: 'route leg too long' },
|
||||
'route leg too long — try again in a moment',
|
||||
translate('{error} — try again in a moment', {
|
||||
error: translate('route leg too long'),
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ status: 429, refusal: null },
|
||||
'Routing is rate limited — try again in a moment',
|
||||
translate('Routing is rate limited — try again in a moment'),
|
||||
],
|
||||
[
|
||||
{ status: 503 },
|
||||
'Routing unavailable (HTTP 503)',
|
||||
translate('Routing unavailable (HTTP {status})', { status: 503 }),
|
||||
],
|
||||
[
|
||||
{ status: 0, message: '' },
|
||||
'Routing unavailable',
|
||||
translate('Routing unavailable'),
|
||||
],
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
for (const [state, english] of cases) {
|
||||
const stats = directionsStats(state);
|
||||
assert.equal(stats.error, english);
|
||||
const row = rowFor(stats);
|
||||
assert.ok(row.includes(translate(english)), `${locale}: ${row}`);
|
||||
assert.ok(!row.includes(english), `${locale}: ${row}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
for (const [failure, english, shown] of failures()) {
|
||||
const error = presentRoutingFailure(failure);
|
||||
assert.equal(error, english);
|
||||
const stats = directionsStats({ ...idle, status: 'error', error });
|
||||
assert.equal(stats.error, english);
|
||||
assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`);
|
||||
assert.notEqual(shown, english);
|
||||
}
|
||||
// The proxy's refusal after "Routing failed:", and a text it never sends.
|
||||
for (const [refusal, shownRefusal] of [
|
||||
['route too long', translate('route too long')],
|
||||
['something else', 'something else'],
|
||||
]) {
|
||||
const row = rowFor(
|
||||
directionsStats({
|
||||
...idle,
|
||||
status: 'error',
|
||||
error: `Routing failed: ${refusal}`,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
row.includes(
|
||||
translate('Routing failed: {error}', { error: shownRefusal }),
|
||||
),
|
||||
`${locale}: ${row}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: the Directions row shows the English error', () => {
|
||||
setLocale('en');
|
||||
const row = rowFor(directionsStats({ ...idle, status: 'error' }));
|
||||
assert.match(row, /No route found/);
|
||||
});
|
||||
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
|
||||
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
|
||||
*
|
||||
* Two inputs feed it. The receiver is any object with `getState()` returning
|
||||
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||||
* `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||||
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
|
||||
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
|
||||
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
|
||||
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
|
||||
* text of an `error` status (`message`, the UI-language one, stands in for a
|
||||
* receiver without it). The browser WebUSB session is one such receiver.
|
||||
* The optional `feeds` session (`./feeds.js`) supplies records read
|
||||
* by the server from local 1090/978 MHz decoders; it polls only while this
|
||||
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
||||
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
||||
|
||||
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
|
||||
|
||||
function webUsbStatus(receiver, heard, feedState) {
|
||||
if (receiver.status === 'error')
|
||||
return { status: 'error', error: receiver.message };
|
||||
return { status: 'error', error: receiver.errorText ?? receiver.message };
|
||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||
return { loading: true, loadingLabel: msg('opening receiver') };
|
||||
if (
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// "USB error", feed problems, even as the whole error) read in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { SdrController } from '../../sdr/controller.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { localAdsbStatus } from './status.js';
|
||||
|
||||
@@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A receiver error is English data for the layer (stats, and what the voice
|
||||
// tools and the analyst read); the Radio card and the row show it translated.
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => {
|
||||
const receiver = new SdrController({
|
||||
storage: null,
|
||||
webUsb: {},
|
||||
providerFactory: () => ({
|
||||
get: async () => {
|
||||
throw new Error('No device was selected.');
|
||||
},
|
||||
}),
|
||||
});
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.equal(await receiver.connect('adsb'), false);
|
||||
const state = receiver.getState();
|
||||
assert.equal(state.status, 'error');
|
||||
assert.equal(state.errorText, 'No RTL-SDR device selected');
|
||||
assert.equal(state.message, t('No RTL-SDR device selected'));
|
||||
assert.notEqual(state.message, state.errorText);
|
||||
|
||||
const status = localAdsbStatus({
|
||||
receiver: state,
|
||||
feedState: null,
|
||||
heard: 0,
|
||||
});
|
||||
assert.equal(status.error, 'No RTL-SDR device selected');
|
||||
const row = rowFor(status, 0);
|
||||
assert.ok(row.includes(state.message), row);
|
||||
assert.doesNotMatch(row, /No RTL-SDR device selected/, row);
|
||||
|
||||
// The English text goes with the error: stop() clears it.
|
||||
await receiver.stop();
|
||||
assert.equal(receiver.getState().errorText, null);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
await receiver.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
// The proxy's last snapshot, served while WFIGS is unreachable.
|
||||
let _stale = false;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_enabled = false;
|
||||
console.log('[Data:FirePerimeters] Initialized');
|
||||
},
|
||||
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
|
||||
_request = request;
|
||||
refreshInciwebIndex(request.signal);
|
||||
try {
|
||||
const rows = await source.getSnapshot({ signal: request.signal });
|
||||
// The source contract is getSnapshot; a source that can also say how
|
||||
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
|
||||
const snapshot =
|
||||
typeof source.getSnapshotDetail === 'function'
|
||||
? await source.getSnapshotDetail({ signal: request.signal })
|
||||
: { rows: await source.getSnapshot({ signal: request.signal }) };
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
const { rows } = snapshot;
|
||||
_stale = snapshot.stale === true;
|
||||
// A stale snapshot is as old as its fetch, not this poll.
|
||||
const updatedAt =
|
||||
_stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? snapshot.fetchedAt
|
||||
: Date.now();
|
||||
|
||||
// Incident facts may change independently of the polygon's source clock.
|
||||
const signature = JSON.stringify(
|
||||
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
|
||||
// Refresh (or drop) the selected incident's card against the new feed.
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || msg('Perimeter source unavailable');
|
||||
// A failure now outranks the stale snapshot still drawn.
|
||||
_stale = false;
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
},
|
||||
|
||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
stale: _stale,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
|
||||
if (action !== 'destroy') h.layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
|
||||
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
|
||||
let snapshot = { rows: [row], stale: true, fetchedAt };
|
||||
const h = harness({
|
||||
getSnapshot: async () => snapshot.rows,
|
||||
getSnapshotDetail: async () => snapshot,
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||
});
|
||||
|
||||
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => [row],
|
||||
getSnapshotDetail: async () => {
|
||||
if (fail) throw new Error('WFIGS HTTP 502');
|
||||
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||
},
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
fail = true;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
|
||||
});
|
||||
@@ -8,20 +8,35 @@ const msg = (text) => text;
|
||||
export function createWfigsPerimeterSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/**
|
||||
* The rows with how fresh they are: the proxy answers from its last
|
||||
* snapshot, marked stale, while WFIGS is unreachable.
|
||||
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||
*/
|
||||
async function getSnapshotDetail({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter snapshot'));
|
||||
return {
|
||||
rows: payload.rows,
|
||||
stale: payload.stale === true,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter snapshot'));
|
||||
return payload.rows;
|
||||
/** The perimeter rows (see getSnapshotDetail for staleness). */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
};
|
||||
}
|
||||
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
|
||||
let clock = 1_000_000_000;
|
||||
let up = true;
|
||||
const plugin = firePerimetersProxy({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
if (!up) throw new Error('WFIGS unreachable');
|
||||
return Response.json(validPayload);
|
||||
},
|
||||
});
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_path, callback) => (handler = callback) },
|
||||
});
|
||||
const ask = async () => {
|
||||
const reply = {};
|
||||
await handler(
|
||||
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
|
||||
{
|
||||
writeHead(status, headers) {
|
||||
reply.status = status;
|
||||
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
|
||||
},
|
||||
end(body) {
|
||||
reply.body = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
return reply;
|
||||
};
|
||||
const fresh = await ask();
|
||||
assert.equal(fresh.status, 200);
|
||||
assert.equal(fresh.body.stale, undefined);
|
||||
up = false;
|
||||
clock += 23 * 60 * 60 * 1000;
|
||||
const stale = await ask();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.staleHeader, true);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
|
||||
clock += 2 * 60 * 60 * 1000;
|
||||
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
|
||||
});
|
||||
|
||||
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 9, 25, 12);
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||
rows: [],
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot(), []);
|
||||
});
|
||||
|
||||
test('getSnapshot works when taken off its source object', async () => {
|
||||
const { getSnapshot } = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [] }),
|
||||
});
|
||||
assert.deepEqual(await getSnapshot(), []);
|
||||
});
|
||||
Vendored
+15
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
liveMode: layerState._liveMode,
|
||||
fetching: loading,
|
||||
flowError: layerState._flowError,
|
||||
stale: layerState._flowStale,
|
||||
coveragePct: flowCoveragePct,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
roadSource: layerState._roadSource,
|
||||
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading,
|
||||
mode: feed.mode,
|
||||
// Cached proxy tiles (TomTom budget spent or upstream failing): the
|
||||
// manager reads this as STALE. Any error (flow, roads, detail) reads
|
||||
// DEGRADED instead.
|
||||
stale: Boolean(
|
||||
layerState._liveMode &&
|
||||
layerState._flowStale &&
|
||||
!(
|
||||
layerState._roadError ||
|
||||
layerState._roadWarning ||
|
||||
layerState._detailError ||
|
||||
feed.error
|
||||
),
|
||||
),
|
||||
oldestTileAt: layerState._flowOldestTileAt ?? null,
|
||||
error:
|
||||
layerState._roadError ||
|
||||
layerState._roadWarning ||
|
||||
|
||||
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
return layerState._warmFlow;
|
||||
layerState._warmFlowGeneration = generation;
|
||||
const signal = layerState._activeFetchAbort?.signal;
|
||||
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
|
||||
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
|
||||
if (!layerState._liveMode || generation !== layerState._loadGeneration)
|
||||
return [];
|
||||
return fetchFlowForBounds(clamped, { signal });
|
||||
if (typeof source.fetchFlowDetail !== 'function')
|
||||
return fetchFlowForBounds(clamped, { signal });
|
||||
// The proxy serves cached tiles once the TomTom budget is spent or the
|
||||
// upstream fails; carry that mark so getStats() reads STALE, not LIVE.
|
||||
const detail = await source.fetchFlowDetail(clamped, { signal });
|
||||
if (generation === layerState._loadGeneration) {
|
||||
layerState._flowStale = detail.stale === true;
|
||||
layerState._flowOldestTileAt = detail.oldestTileAt ?? null;
|
||||
}
|
||||
return detail.segments;
|
||||
});
|
||||
layerState._warmFlow.catch(() => {});
|
||||
return layerState._warmFlow;
|
||||
|
||||
@@ -42,6 +42,8 @@ export function createLifecycle({
|
||||
layerState._lastViewCenter = null;
|
||||
layerState._flowRoads = null;
|
||||
layerState._flowError = null;
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
if (TRAFFIC_TIMING_ENABLED) {
|
||||
layerState._trafficTimingCurrentAnchor = null;
|
||||
layerState._trafficTimingSequence = 0;
|
||||
@@ -159,6 +161,8 @@ export function createLifecycle({
|
||||
// A stale outage from the last session would misreport a fresh enable —
|
||||
// the next load re-derives feed health from real evidence.
|
||||
layerState._flowError = null;
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
|
||||
layerState._surfaceFrameRemover?.();
|
||||
layerState._surfaceFrameRemover = null;
|
||||
|
||||
@@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
|
||||
* @param {boolean} [input.fetching] - A viewport load is in flight.
|
||||
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
|
||||
* @param {boolean} [input.stale] - The flow came from cached proxy tiles.
|
||||
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
|
||||
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
|
||||
* @param {string} [input.roadSource] - Name of the geometry being drawn.
|
||||
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
liveMode = false,
|
||||
fetching = false,
|
||||
flowError = null,
|
||||
stale = false,
|
||||
coveragePct = 0,
|
||||
statusUnavailable = false,
|
||||
roadSource = 'OpenStreetMap',
|
||||
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
});
|
||||
return { mode, error: degraded, loadingLabel: degraded };
|
||||
}
|
||||
// Cached proxy tiles (TomTom budget spent or upstream failing) are not live.
|
||||
if (liveMode && stale && !fetching) {
|
||||
return {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', {
|
||||
source: roadSource,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
||||
if (liveMode && roadSource === 'TomTom') {
|
||||
return {
|
||||
|
||||
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
|
||||
assert.equal(state._flowPending, 0);
|
||||
});
|
||||
|
||||
test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => {
|
||||
const { createFlow } = await import('./flow.js');
|
||||
const { createModel } = await import('./model.js');
|
||||
const { createControls } = await import('./controls.js');
|
||||
const { createState } = await import('./state.js');
|
||||
const { layerFeedState } = await import('../../data/feedState.js');
|
||||
let detail = {
|
||||
segments: [flow],
|
||||
partial: false,
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
};
|
||||
const source = {
|
||||
fetchFlowDetail: async () => detail,
|
||||
fetchFlowForBounds: async () => detail.segments,
|
||||
getFlowSessionStats: () => ({ tilesFetched: 1 }),
|
||||
};
|
||||
const state = createState({ services: {} });
|
||||
Object.assign(state, {
|
||||
_enabled: true,
|
||||
_liveMode: true,
|
||||
_loadGeneration: 1,
|
||||
_flowStatusPromise: Promise.resolve(),
|
||||
_bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 },
|
||||
});
|
||||
const parts = {};
|
||||
const context = { state, services: { credits: {} }, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.flow = createFlow(context);
|
||||
const controls = createControls(context);
|
||||
|
||||
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1);
|
||||
const stale = controls.methods.getStats();
|
||||
assert.equal(stale.mode, 'live');
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.oldestTileAt, 1000);
|
||||
assert.doesNotMatch(stale.loadingLabel, /LIVE/);
|
||||
assert.equal(layerFeedState(stale), 'stale');
|
||||
|
||||
// A road failure outranks cached flow: the row reads the failure.
|
||||
state._roadError = 'Road geometry unavailable';
|
||||
assert.equal(controls.methods.getStats().stale, false);
|
||||
state._roadError = null;
|
||||
|
||||
detail = {
|
||||
segments: [flow],
|
||||
partial: false,
|
||||
stale: false,
|
||||
oldestTileAt: 2000,
|
||||
};
|
||||
state._loadGeneration = 2;
|
||||
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2);
|
||||
const live = controls.methods.getStats();
|
||||
assert.equal(live.stale, false);
|
||||
assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/);
|
||||
assert.equal(layerFeedState(live), 'nominal');
|
||||
});
|
||||
|
||||
test('status names the drawn road source for each mode', async () => {
|
||||
const { createModel } = await import('./model.js');
|
||||
const { trafficFeedPresentation: present } = createModel({
|
||||
|
||||
@@ -108,6 +108,16 @@ export function createState({ services }) {
|
||||
|
||||
layerState._flowError = null;
|
||||
|
||||
/**
|
||||
* True while the drawn flow came from cached proxy tiles (TomTom budget
|
||||
* spent or upstream failing); `_flowOldestTileAt` is the oldest tile's
|
||||
* fetch time in ms, or null.
|
||||
* @type {boolean}
|
||||
*/
|
||||
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
|
||||
/**
|
||||
* True when `/api/tomtom/status` itself could not be reached, so the layer is
|
||||
* simulating because it could not ask — not because the server said "no key".
|
||||
|
||||
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
layerState._fetching = false;
|
||||
layerState._flowPending = 0;
|
||||
layerState._roadError = null;
|
||||
// Nothing is drawn up here, cached or not.
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
parts.animation.clearDots();
|
||||
layerState._lastBounds = null;
|
||||
layerState._lastViewCenter = null;
|
||||
|
||||
+114
-17
@@ -153,6 +153,8 @@ export class SceneDirector {
|
||||
this._runAbort = null;
|
||||
/** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */
|
||||
this._loadAbort = null;
|
||||
/** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */
|
||||
this._seekAbort = null;
|
||||
this._clock = createPlaybackClock({
|
||||
isRunning: () => this._running,
|
||||
timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot),
|
||||
@@ -234,7 +236,7 @@ export class SceneDirector {
|
||||
destroy() {
|
||||
if (this._destroyPromise) return this._destroyPromise;
|
||||
this._destroyed = true;
|
||||
this._sceneSeekGeneration++;
|
||||
this._bumpSceneSeekGeneration();
|
||||
this._visibilityUnsubscribe?.();
|
||||
this._cameraHandoffUnsubscribe?.();
|
||||
this._removeCameraInput?.();
|
||||
@@ -1071,7 +1073,7 @@ export class SceneDirector {
|
||||
loadShot(sceneId, shotId, options) {
|
||||
if (this._destroyed)
|
||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
this._sceneSeekGeneration++;
|
||||
this._bumpSceneSeekGeneration();
|
||||
this._interactionTransitions = 0;
|
||||
const previousGeneration = this._loadGeneration;
|
||||
const work = this._loadShot(sceneId, shotId, options);
|
||||
@@ -1516,9 +1518,30 @@ export class SceneDirector {
|
||||
return new Promise((resolve) => this._runIdleResolvers.add(resolve));
|
||||
}
|
||||
|
||||
/** Apply a new time inside the already-loaded shot without rebuilding its stack. */
|
||||
_seekLoadedShot(scene, seekState) {
|
||||
if (this._destroyed) return false;
|
||||
/**
|
||||
* Supersede any pending seek. Only a seek's own layer reconcile is aborted:
|
||||
* a request that is later rejected must not cancel an in-flight LOAD.
|
||||
*/
|
||||
_bumpSceneSeekGeneration() {
|
||||
if (this._seekAbort) {
|
||||
this._seekAbort.abort();
|
||||
if (this._loadAbort === this._seekAbort) this._loadAbort = null;
|
||||
this._seekAbort = null;
|
||||
}
|
||||
return ++this._sceneSeekGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new time inside the already-loaded shot without rebuilding its stack.
|
||||
* @returns {Promise<boolean|null>} Null when the shortcut does not apply and
|
||||
* LOAD must rebuild the shot; otherwise whether the seek settled.
|
||||
*/
|
||||
async _seekLoadedShot(
|
||||
scene,
|
||||
seekState,
|
||||
seekGeneration = this._sceneSeekGeneration,
|
||||
) {
|
||||
if (this._destroyed) return null;
|
||||
const shot = seekState?.shot;
|
||||
if (
|
||||
!scene ||
|
||||
@@ -1528,17 +1551,77 @@ export class SceneDirector {
|
||||
this._loadedSceneId !== scene.id ||
|
||||
this._selectedShotId !== shot.id
|
||||
)
|
||||
return null;
|
||||
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||
return false;
|
||||
if (!this._claimCameraOwnership()) return false;
|
||||
this._cancelActiveSceneTravel();
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
this._loadGeneration += 1;
|
||||
const generation = ++this._loadGeneration;
|
||||
this._clock.stopShot();
|
||||
const states = this._layerStatesForShot(scene, shot, {
|
||||
cameraSettled: seekState.cameraProgress >= 1,
|
||||
sceneSeek: seekState,
|
||||
});
|
||||
// A manual toggle, or a superseded LOAD transition whose rollback is still
|
||||
// settling, leaves declared layers off the shot's stack. Reconcile only
|
||||
// those through the cancellable admission path.
|
||||
const staleStates = Object.fromEntries(
|
||||
Object.entries(states)
|
||||
.filter(([id, state]) => {
|
||||
const wantEnabled = !!state?.enabled;
|
||||
const lifecycle = this.dataManager.getLayerLifecycleState(id);
|
||||
return (
|
||||
lifecycle &&
|
||||
(lifecycle.enabled !== wantEnabled ||
|
||||
lifecycle.lifecycleState === 'enabling' ||
|
||||
lifecycle.lifecycleState === 'disabling' ||
|
||||
lifecycle.uncertain ||
|
||||
this.dataManager.isEffectivelyEnabled(id) !== wantEnabled)
|
||||
);
|
||||
})
|
||||
.map(([id, state]) => [id, { enabled: !!state?.enabled }]),
|
||||
);
|
||||
if (Object.keys(staleStates).length) {
|
||||
const previousEnabled = new Map(
|
||||
Object.keys(staleStates).map((id) => [
|
||||
id,
|
||||
this.dataManager.isEffectivelyEnabled(id),
|
||||
]),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
this._loadAbort = controller;
|
||||
this._seekAbort = controller;
|
||||
const layerResult = await this._applyLayerStates(
|
||||
staleStates,
|
||||
this._loadToken(generation, controller.signal, seekGeneration),
|
||||
{ stopOnRefusal: true, exitContextMode: false },
|
||||
);
|
||||
if (this._loadAbort === controller) this._loadAbort = null;
|
||||
if (this._seekAbort === controller) this._seekAbort = null;
|
||||
const superseded = () =>
|
||||
this._destroyed ||
|
||||
this._loadAbort ||
|
||||
this._running ||
|
||||
seekGeneration !== this._sceneSeekGeneration;
|
||||
if (layerResult.cancelled || superseded()) return false;
|
||||
if (layerResult.refused.length) {
|
||||
// Only a refusal rolls back. A cancelled reconcile leaves layer state
|
||||
// to whatever superseded it, as a cancelled LOAD does.
|
||||
for (const id of [...layerResult.applied].reverse()) {
|
||||
if (superseded()) break;
|
||||
await this.dataManager.setEnabled(
|
||||
id,
|
||||
previousEnabled.get(id) ?? false,
|
||||
{ origin: 'scene' },
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||
return false;
|
||||
let applied = true;
|
||||
for (const [layerId, state] of Object.entries(states)) {
|
||||
if (!state?.enabled || !state.params) continue;
|
||||
@@ -1550,7 +1633,9 @@ export class SceneDirector {
|
||||
applied = false;
|
||||
}
|
||||
}
|
||||
if (!applied) return false;
|
||||
if (!applied) return null;
|
||||
if (this._destroyed || seekGeneration !== this._sceneSeekGeneration)
|
||||
return false;
|
||||
this._setCameraView(
|
||||
seekState.camera || resolveCameraPose(scene, shot.camera),
|
||||
);
|
||||
@@ -1572,7 +1657,7 @@ export class SceneDirector {
|
||||
seekScene(sceneId, progress) {
|
||||
if (this._destroyed) return Promise.resolve(false);
|
||||
this.stopScene(msg('Seeking scene clock'));
|
||||
const generation = ++this._sceneSeekGeneration;
|
||||
const generation = this._bumpSceneSeekGeneration();
|
||||
return this._trackWork(this._seekScene(sceneId, progress, generation));
|
||||
}
|
||||
|
||||
@@ -1586,7 +1671,10 @@ export class SceneDirector {
|
||||
return false;
|
||||
const seekState = this._sceneSeekState(scene, progress);
|
||||
if (!seekState) return false;
|
||||
if (this._seekLoadedShot(scene, seekState)) return true;
|
||||
const seeked = await this._seekLoadedShot(scene, seekState, generation);
|
||||
if (this._destroyed || generation !== this._sceneSeekGeneration)
|
||||
return false;
|
||||
if (seeked !== null) return seeked;
|
||||
const result = await this._loadShot(scene.id, seekState.shot.id, {
|
||||
sceneSeek: seekState,
|
||||
});
|
||||
@@ -1606,15 +1694,17 @@ export class SceneDirector {
|
||||
* @param {AbortSignal} [signal] - Abort signal for this LOAD's manager calls
|
||||
* @returns {{ cancelled: boolean, signal: AbortSignal|undefined }}
|
||||
*/
|
||||
_loadToken(generation, signal = undefined) {
|
||||
_loadToken(generation, signal = undefined, seekGeneration = null) {
|
||||
const director = this;
|
||||
return {
|
||||
signal,
|
||||
get cancelled() {
|
||||
return (
|
||||
director._destroyed ||
|
||||
signal?.aborted ||
|
||||
director._loadGeneration !== generation
|
||||
Boolean(signal?.aborted) ||
|
||||
director._loadGeneration !== generation ||
|
||||
(seekGeneration !== null &&
|
||||
director._sceneSeekGeneration !== seekGeneration)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1753,7 +1843,7 @@ export class SceneDirector {
|
||||
startScene(sceneId, options) {
|
||||
if (this._destroyed)
|
||||
return Promise.resolve({ started: false, reason: 'destroyed' });
|
||||
this._sceneSeekGeneration++;
|
||||
this._bumpSceneSeekGeneration();
|
||||
return this._trackWork(this._startScene(sceneId, options));
|
||||
}
|
||||
|
||||
@@ -1919,7 +2009,7 @@ export class SceneDirector {
|
||||
this._setSceneMediaPlayback();
|
||||
this._interactions?.clear();
|
||||
this._dataPacks?.clear();
|
||||
this._sceneSeekGeneration++;
|
||||
this._bumpSceneSeekGeneration();
|
||||
this._loadAbort?.abort();
|
||||
this._loadAbort = null;
|
||||
this._loadGeneration++;
|
||||
@@ -2165,7 +2255,11 @@ export class SceneDirector {
|
||||
* the transition in flight.
|
||||
* @returns {Promise<{ applied: string[], refused: string[], cancelled: boolean }>}
|
||||
*/
|
||||
async _applyLayerStates(targetStates, token = null) {
|
||||
async _applyLayerStates(
|
||||
targetStates,
|
||||
token = null,
|
||||
{ stopOnRefusal = false, exitContextMode = true } = {},
|
||||
) {
|
||||
const applied = [];
|
||||
const refused = [];
|
||||
const abort = () => ({ applied, refused, cancelled: true });
|
||||
@@ -2175,8 +2269,10 @@ export class SceneDirector {
|
||||
// the operator's pre-mode state, which is exactly where a stopped scene
|
||||
// should come to rest. Tearing that transaction in half would strand
|
||||
// Context, so it completes and cancellation is honoured immediately after.
|
||||
await this._exitIsolatingContextMode();
|
||||
if (token?.cancelled) return abort();
|
||||
if (exitContextMode) {
|
||||
await this._exitIsolatingContextMode();
|
||||
if (token?.cancelled) return abort();
|
||||
}
|
||||
|
||||
const signal = token?.signal;
|
||||
const registered = new Set(
|
||||
@@ -2206,6 +2302,7 @@ export class SceneDirector {
|
||||
console.warn(
|
||||
`[Scenes] Layer refused: ${id} → ${enabled ? 'on' : 'off'}`,
|
||||
);
|
||||
if (stopOnRefusal) break;
|
||||
continue;
|
||||
}
|
||||
if (params && typeof this.dataManager.restoreLayerState !== 'function') {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { readShellSource } from '../testSupport/readShellSource.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { SceneDirector } from './director.js';
|
||||
import { SCENE_TRACKING_PARAM_KEYS } from './scenePolicy.js';
|
||||
import { SCENE_RECIPES, getSceneAppendRecipeById } from './recipes.js';
|
||||
@@ -174,6 +175,348 @@ test('scene clock seek resolves the exact shot phase and camera in both directio
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking restores declared layer visibility after manual changes', async () => {
|
||||
const { director, dataManager, styleManager, restore } = makeDirector();
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.layers = { flights: { enabled: true }, traffic: { enabled: false } };
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
await dataManager.setEnabled('flights', false);
|
||||
await dataManager.setEnabled('traffic', true);
|
||||
await dataManager.setEnabled('satellites', true);
|
||||
dataManager.setEnabledCalls.length = 0;
|
||||
const visualCalls = styleManager.visualCalls.length;
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
||||
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
||||
assert.equal(enabled.get('flights'), true, 'restore the shot\'s enabled layer');
|
||||
assert.equal(enabled.get('traffic'), false, 'restore the shot\'s disabled layer');
|
||||
assert.equal(enabled.get('satellites'), true, 'preserve undeclared operator layers');
|
||||
assert.ok(dataManager.setEnabledCalls.every(({ id }) => id !== 'satellites'));
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking reports refusal when a manually disabled layer cannot be restored', async () => {
|
||||
let refuse = false;
|
||||
const { director, dataManager, styleManager, restore } = makeDirector({
|
||||
data: { refuse: () => refuse },
|
||||
});
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
await dataManager.setEnabled('flights', false);
|
||||
refuse = true;
|
||||
const visualCalls = styleManager.visualCalls.length;
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, false);
|
||||
const timing = director.getPlaybackTimingState();
|
||||
assert.equal(timing.activeTimers, 0);
|
||||
assert.ok(Math.abs(timing.snapshot.sceneProgress - 0.1) < 1e-9, 'keep the last settled seek position');
|
||||
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking restores visibility a manual toggle is still changing', async () => {
|
||||
const { director, dataManager, restore } = makeDirector();
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
const disabling = dataManager.setEnabled('flights', false);
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
await disabling;
|
||||
await settle();
|
||||
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking restores a layer whose superseded LOAD transition rolls back', async () => {
|
||||
const { director, dataManager, styleManager, viewer, restore } = makeDirector();
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
for (const id of ['flights', 'traffic', 'satellites']) {
|
||||
lifecycle.register({
|
||||
id,
|
||||
name: id,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
}
|
||||
const realDirector = new SceneDirector(viewer, styleManager, lifecycle);
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
await dataManager.setEnabled('flights', false);
|
||||
dataManager.setEnabledCalls.length = 0;
|
||||
const reload = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
for (let i = 0; i < 20 && !dataManager.setEnabledCalls.length; i++) await Promise.resolve();
|
||||
assert.equal(dataManager.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
await reload;
|
||||
await settle();
|
||||
assert.equal(dataManager.getAll().find(({ id }) => id === 'flights').enabled, true);
|
||||
|
||||
await realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
await lifecycle.setEnabled('flights', false);
|
||||
const queuedReload = realDirector.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
for (let i = 0; i < 20 && !lifecycle.isEffectivelyEnabled('flights'); i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.deepEqual(lifecycle.getLayerLifecycleState('flights'), {
|
||||
enabled: false,
|
||||
lifecycleState: 'disabled',
|
||||
uncertain: false,
|
||||
});
|
||||
assert.equal(await realDirector.seekScene('scene-1', 0.25), true);
|
||||
await queuedReload;
|
||||
await settle();
|
||||
assert.equal(lifecycle.isEnabled('flights'), true);
|
||||
|
||||
lifecycle.layers.get('flights').lifecycleUncertain = true;
|
||||
assert.equal(await realDirector.seekScene('scene-1', 0.3), true);
|
||||
assert.equal(lifecycle.getLayerLifecycleState('flights').uncertain, false);
|
||||
|
||||
let navClaims = 0;
|
||||
styleManager.runImmediateNavigation = () => {
|
||||
navClaims += 1;
|
||||
return false;
|
||||
};
|
||||
assert.equal(await realDirector.seekScene('scene-1', 0.4), false);
|
||||
assert.equal(navClaims, 1, 'camera refusal fails the seek without falling back to LOAD');
|
||||
} finally {
|
||||
await realDirector.destroy();
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking rolls back earlier layers and preserves Context, clock and camera when a later layer is refused', async () => {
|
||||
let refuse = () => false;
|
||||
const { director, dataManager, styleManager, viewer, restore } = makeDirector({
|
||||
data: { refuse: (id, on) => refuse(id, on) },
|
||||
});
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.layers = {
|
||||
flights: { enabled: true },
|
||||
traffic: { enabled: true },
|
||||
satellites: { enabled: true },
|
||||
};
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
await dataManager.setEnabled('flights', false);
|
||||
await dataManager.setEnabled('traffic', false);
|
||||
await dataManager.setEnabled('satellites', false);
|
||||
styleManager.contextMode = 'space-missions';
|
||||
styleManager.contextExits.length = 0;
|
||||
refuse = (id) => id === 'traffic';
|
||||
dataManager.setEnabledCalls.length = 0;
|
||||
const viewsBefore = viewer.views.length;
|
||||
const visualCalls = styleManager.visualCalls.length;
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||
assert.equal(
|
||||
dataManager.setEnabledCalls.some(({ id }) => id === 'satellites'),
|
||||
false,
|
||||
'stop the plan before later ids are written',
|
||||
);
|
||||
const enabled = new Map(dataManager.getAll().map(({ id, enabled }) => [id, enabled]));
|
||||
assert.equal(enabled.get('flights'), false, 'first stale layer stays disabled');
|
||||
assert.equal(enabled.get('traffic'), false, 'refused second layer stays disabled');
|
||||
assert.equal(enabled.get('satellites'), false, 'later stale layer is never written');
|
||||
assert.equal(styleManager.contextMode, 'space-missions');
|
||||
assert.deepEqual(styleManager.contextExits, []);
|
||||
assert.equal(viewer.views.length, viewsBefore, 'camera stays at the pre-seek pose');
|
||||
assert.equal(styleManager.visualCalls.length, visualCalls, 'do not rebuild the shot');
|
||||
assert.ok(
|
||||
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
||||
'clock stays at the last settled seek position',
|
||||
);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking aborts in-flight admission when superseded by an early-return load or start', async () => {
|
||||
const { styleManager, viewer, restore } = makeDirector();
|
||||
let holdEnable = false;
|
||||
let releaseEnable;
|
||||
const enableGate = new Promise((resolve) => {
|
||||
releaseEnable = resolve;
|
||||
});
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
init() {},
|
||||
async enable() {
|
||||
if (holdEnable) await enableGate;
|
||||
},
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
await lifecycle.setEnabled('flights', false);
|
||||
holdEnable = true;
|
||||
const viewsBefore = viewer.views.length;
|
||||
|
||||
const seek = director.seekScene('scene-1', 0.25);
|
||||
for (
|
||||
let i = 0;
|
||||
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
||||
i++
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||
const admissionSignal = director._loadAbort?.signal;
|
||||
assert.ok(admissionSignal, 'same-shot seek installs an admission controller');
|
||||
|
||||
const missingLoad = director.loadShot('missing', 'nope');
|
||||
assert.equal(
|
||||
admissionSignal.aborted,
|
||||
true,
|
||||
'missing load aborts the admission controller before returning',
|
||||
);
|
||||
assert.equal((await missingLoad).reason, 'shot-not-found');
|
||||
assert.equal(viewer.views.length, viewsBefore, 'shortcut has not committed before abort');
|
||||
|
||||
releaseEnable();
|
||||
assert.equal(await seek, false);
|
||||
await settle();
|
||||
assert.equal(lifecycle.isEnabled('flights'), false, 'layer is not restored');
|
||||
assert.equal(viewer.views.length, viewsBefore, 'setView is not called');
|
||||
assert.ok(
|
||||
Math.abs(director.getPlaybackTimingState().snapshot.sceneProgress - 0.1) < 1e-9,
|
||||
'sceneProgress stays at the last settled value',
|
||||
);
|
||||
|
||||
let loadFallbackCalls = 0;
|
||||
const origLoadShot = director._loadShot.bind(director);
|
||||
director._loadShot = (...args) => {
|
||||
loadFallbackCalls += 1;
|
||||
return origLoadShot(...args);
|
||||
};
|
||||
const origSeekLoadedShot = director._seekLoadedShot.bind(director);
|
||||
director._seekLoadedShot = async () => {
|
||||
await director.startScene('missing');
|
||||
return null;
|
||||
};
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||
assert.equal(loadFallbackCalls, 0, 'stale seek returning null must not start _loadShot');
|
||||
director._seekLoadedShot = origSeekLoadedShot;
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a rejected loadShot does not cancel an in-flight LOAD', async () => {
|
||||
const { styleManager, viewer, restore } = makeDirector();
|
||||
let holdEnable = true;
|
||||
let releaseEnable;
|
||||
const enableGate = new Promise((resolve) => {
|
||||
releaseEnable = resolve;
|
||||
});
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({
|
||||
id: 'flights',
|
||||
name: 'flights',
|
||||
init() {},
|
||||
async enable() {
|
||||
if (holdEnable) await enableGate;
|
||||
},
|
||||
disable() {},
|
||||
update() {},
|
||||
});
|
||||
const director = new SceneDirector(viewer, styleManager, lifecycle);
|
||||
try {
|
||||
const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
for (
|
||||
let i = 0;
|
||||
i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling';
|
||||
i++
|
||||
) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling');
|
||||
|
||||
assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found');
|
||||
holdEnable = false;
|
||||
releaseEnable();
|
||||
assert.equal((await load)?.started, true, 'the valid LOAD still lands');
|
||||
assert.equal(lifecycle.isEnabled('flights'), true);
|
||||
} finally {
|
||||
releaseEnable?.();
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => {
|
||||
let refuse = () => false;
|
||||
const { director, dataManager, restore } = makeDirector({
|
||||
data: { refuse: (id, on) => refuse(id, on) },
|
||||
});
|
||||
try {
|
||||
const shot = director._project.scenes[0].shots[0];
|
||||
shot.layers = { flights: { enabled: true }, traffic: { enabled: true } };
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
assert.equal(await director.seekScene('scene-1', 0.1), true);
|
||||
await dataManager.setEnabled('flights', false);
|
||||
await dataManager.setEnabled('traffic', false);
|
||||
refuse = (id) => id === 'traffic';
|
||||
const manualEnable = dataManager.setEnabled('flights', true);
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), false);
|
||||
await manualEnable;
|
||||
await settle();
|
||||
assert.equal(
|
||||
dataManager.getAll().find(({ id }) => id === 'flights').enabled,
|
||||
true,
|
||||
'rollback restores the operator\'s intended state, not the stale committed one',
|
||||
);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => {
|
||||
const { director, dataManager, styleManager, restore } = makeDirector();
|
||||
try {
|
||||
await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 });
|
||||
await dataManager.setEnabled('satellites', true);
|
||||
dataManager.setEnabledCalls.length = 0;
|
||||
const visualCalls = styleManager.visualCalls.length;
|
||||
|
||||
assert.equal(await director.seekScene('scene-1', 0.25), true);
|
||||
assert.deepEqual(dataManager.setEnabledCalls, []);
|
||||
assert.equal(styleManager.visualCalls.length, visualCalls);
|
||||
assert.equal(director.getPlaybackTimingState().snapshot.sceneProgress, 0.25);
|
||||
} finally {
|
||||
await director.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('scene clock subscribers receive authoritative forward playback snapshots', () => {
|
||||
const { director, restore } = makeDirector();
|
||||
const snapshots = [];
|
||||
@@ -647,6 +990,7 @@ function installSceneRuntime(project = PROJECT_FIXTURE, { storage } = {}) {
|
||||
*/
|
||||
function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {}) {
|
||||
const enabled = new Map();
|
||||
const pending = new Map();
|
||||
const setEnabledCalls = [];
|
||||
const setParamsCalls = [];
|
||||
const committed = [];
|
||||
@@ -659,12 +1003,25 @@ function fakeDataManager({ registered = REGISTERED, refuse = () => false } = {})
|
||||
setParamsCalls,
|
||||
committed,
|
||||
getAll: () => registered.map((id) => ({ id, enabled: !!enabled.get(id) })),
|
||||
getLayerLifecycleState: (id) => {
|
||||
if (!registered.includes(id)) return null;
|
||||
const on = !!enabled.get(id);
|
||||
const target = pending.get(id);
|
||||
return {
|
||||
enabled: on,
|
||||
lifecycleState:
|
||||
target === undefined ? (on ? 'enabled' : 'disabled') : target ? 'enabling' : 'disabling',
|
||||
};
|
||||
},
|
||||
isEffectivelyEnabled: (id) => pending.get(id) ?? !!enabled.get(id),
|
||||
getLayerParams: () => null,
|
||||
async setEnabled(id, shouldEnable, { signal } = {}) {
|
||||
setEnabledCalls.push({ id, enabled: shouldEnable, signal });
|
||||
if (refuse(id, shouldEnable)) return false;
|
||||
// Yield once so a stop landing during the transition is observable.
|
||||
pending.set(id, shouldEnable);
|
||||
await Promise.resolve();
|
||||
if (pending.get(id) === shouldEnable) pending.delete(id);
|
||||
if (signal?.aborted) return false;
|
||||
enabled.set(id, shouldEnable);
|
||||
committed.push({ id, enabled: shouldEnable });
|
||||
@@ -707,15 +1064,20 @@ function fakeStyleManager({ contextMode = null, exitFails = false } = {}) {
|
||||
/** Cesium viewer double whose flights complete on the next microtask turn. */
|
||||
function fakeViewer() {
|
||||
const flights = [];
|
||||
const views = [];
|
||||
let cancelled = 0;
|
||||
return {
|
||||
flights,
|
||||
views,
|
||||
get cancelledFlights() { return cancelled; },
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
Promise.resolve().then(() => options.complete?.());
|
||||
},
|
||||
setView(options) {
|
||||
views.push(options);
|
||||
},
|
||||
cancelFlight() { cancelled++; },
|
||||
},
|
||||
};
|
||||
|
||||
+34
-34
@@ -283,22 +283,33 @@ function clampFmFrequency(value) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A failed connection in English; errorState() shows it translated. */
|
||||
function describeConnectionError(error) {
|
||||
const message = String(error?.message || error || '').trim();
|
||||
if (/No device was selected|NotFoundError/i.test(message))
|
||||
return t('No RTL-SDR device selected');
|
||||
return msg('No RTL-SDR device selected');
|
||||
if (
|
||||
/does not support the WebUSB|usb/i.test(message) &&
|
||||
/support/i.test(message)
|
||||
) {
|
||||
return t('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
|
||||
return msg('WebUSB requires desktop Chrome or Edge on localhost/HTTPS');
|
||||
}
|
||||
if (/access|permission|claim|busy/i.test(message)) {
|
||||
return t(
|
||||
return msg(
|
||||
'RTL-SDR access failed; close other SDR apps and check USB permissions',
|
||||
);
|
||||
}
|
||||
return message ? translateKnown(message) : t('RTL-SDR connection failed');
|
||||
return message || msg('RTL-SDR connection failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* State fields for a receiver error: `message` in the UI language for the
|
||||
* Radio card, `errorText` as English data for the Local ADS-B layer's stats.
|
||||
* A new connection, a mode switch and stop() clear `errorText` again.
|
||||
* @param {string} text English error text (ours or the browser's).
|
||||
*/
|
||||
function errorState(text) {
|
||||
return { status: 'error', message: translateKnown(text), errorText: text };
|
||||
}
|
||||
|
||||
function streamingMessage(mode) {
|
||||
@@ -350,6 +361,7 @@ export class SdrController {
|
||||
gain: this._gainByMode.fm,
|
||||
seeking: false,
|
||||
seekMessage: '',
|
||||
errorText: null,
|
||||
snr: null,
|
||||
aircraft: [],
|
||||
decodedMessages: 0,
|
||||
@@ -555,10 +567,7 @@ export class SdrController {
|
||||
// A failed worker (e.g. its module did not load) never recovers: drop
|
||||
// it so the next session builds and configures a fresh one.
|
||||
this._discardWorker();
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: t('SDR decoder worker failed'),
|
||||
});
|
||||
this._setState(errorState(msg('SDR decoder worker failed')));
|
||||
if (this._device) void this.stop({ preserveMessage: true });
|
||||
};
|
||||
return worker;
|
||||
@@ -779,16 +788,16 @@ export class SdrController {
|
||||
// Published before stopping; stop() preserves it, and nothing is set
|
||||
// afterwards, so a reconnect started during teardown keeps its own state.
|
||||
this._setState({
|
||||
status: 'error',
|
||||
...errorState(
|
||||
reason === 'startup'
|
||||
? msg('SDR decoder did not start; receiver stopped')
|
||||
: reason === 'stalled'
|
||||
? msg('SDR decoder stopped responding; receiver stopped')
|
||||
: msg('SDR decoder fell behind; receiver stopped'),
|
||||
),
|
||||
connected: false,
|
||||
decoderOverload: reason,
|
||||
decoderOverloadSnapshot: snapshot,
|
||||
message:
|
||||
reason === 'startup'
|
||||
? t('SDR decoder did not start; receiver stopped')
|
||||
: reason === 'stalled'
|
||||
? t('SDR decoder stopped responding; receiver stopped')
|
||||
: t('SDR decoder fell behind; receiver stopped'),
|
||||
});
|
||||
void this.stop({ preserveMessage: true });
|
||||
}
|
||||
@@ -1075,6 +1084,7 @@ export class SdrController {
|
||||
this._setState({
|
||||
status: 'connecting',
|
||||
message: t('Waiting for an RTL-SDR device…'),
|
||||
errorText: null,
|
||||
});
|
||||
let acquisition = null;
|
||||
try {
|
||||
@@ -1085,7 +1095,7 @@ export class SdrController {
|
||||
// consumes the click activation.
|
||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||
throw new Error(
|
||||
t('Browser audio is suspended; press Connect again to resume it'),
|
||||
msg('Browser audio is suspended; press Connect again to resume it'),
|
||||
);
|
||||
}
|
||||
if (superseded()) return false;
|
||||
@@ -1153,8 +1163,7 @@ export class SdrController {
|
||||
if (superseded()) return false;
|
||||
this._setState({
|
||||
connected: false,
|
||||
status: 'error',
|
||||
message: describeConnectionError(error),
|
||||
...errorState(describeConnectionError(error)),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -1507,8 +1516,7 @@ export class SdrController {
|
||||
console.warn('[SDR] Sample read failed:', error);
|
||||
this._setState({
|
||||
connected: false,
|
||||
status: 'error',
|
||||
message: t('RTL-SDR sample stream stopped'),
|
||||
...errorState(msg('RTL-SDR sample stream stopped')),
|
||||
});
|
||||
await this.stop({ preserveMessage: true });
|
||||
break;
|
||||
@@ -1549,11 +1557,12 @@ export class SdrController {
|
||||
message: t('Switching to {mode}…', {
|
||||
mode: nextMode === 'adsb' ? 'ADS-B' : 'FM',
|
||||
}),
|
||||
errorText: null,
|
||||
});
|
||||
try {
|
||||
if (nextMode === 'fm' && !(await this._ensureAudio())) {
|
||||
throw new Error(
|
||||
t('Browser audio is suspended; select FM again to resume it'),
|
||||
msg('Browser audio is suspended; select FM again to resume it'),
|
||||
);
|
||||
}
|
||||
// A stall teardown, stop() or reconnect during the switch supersedes
|
||||
@@ -1578,10 +1587,7 @@ export class SdrController {
|
||||
} catch (error) {
|
||||
if (!this._owns(token, device)) return false;
|
||||
console.warn('[SDR] Mode switch failed:', error);
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: describeConnectionError(error),
|
||||
});
|
||||
this._setState(errorState(describeConnectionError(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1622,10 +1628,7 @@ export class SdrController {
|
||||
if (targetMode === 'adsb') this._setState(this._resetAdsbCounters());
|
||||
} catch (error) {
|
||||
console.warn('[SDR] Gain change failed:', error);
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: t('RTL-SDR gain change failed'),
|
||||
});
|
||||
this._setState(errorState(msg('RTL-SDR gain change failed')));
|
||||
return null;
|
||||
}
|
||||
return setting;
|
||||
@@ -1679,10 +1682,7 @@ export class SdrController {
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this._setState({
|
||||
status: 'error',
|
||||
message: describeConnectionError(error),
|
||||
});
|
||||
this._setState(errorState(describeConnectionError(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1873,7 +1873,7 @@ export class SdrController {
|
||||
status: preserveMessage ? this.state.status : idleStatus,
|
||||
...(preserveMessage
|
||||
? {}
|
||||
: { message: message || t('RTL-SDR disconnected') }),
|
||||
: { message: message || t('RTL-SDR disconnected'), errorText: null }),
|
||||
snr: null,
|
||||
samplesPerSecond: 0,
|
||||
workerBlocks: 0,
|
||||
|
||||
@@ -71,6 +71,7 @@ export function createApplicationRequestServices({
|
||||
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
|
||||
// retried for a short backoff, during which queries go to the server as
|
||||
// they would against an older server without the probe.
|
||||
let summaryCapability = null;
|
||||
let boundaryCapability = null;
|
||||
let boundaryProbeRetryAt = 0;
|
||||
function boundariesConfigured() {
|
||||
@@ -175,6 +176,23 @@ export function createApplicationRequestServices({
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
// The capability probe, asked once per page: true or false from a
|
||||
// server that answers it, null (unknown) from one that does not.
|
||||
configured() {
|
||||
summaryCapability ??= request(`${urls.summary}/status`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
}).then(
|
||||
(response) =>
|
||||
response.ok && typeof response.data?.configured === 'boolean'
|
||||
? response.data.configured
|
||||
: null,
|
||||
(error) => {
|
||||
if (lifetime?.aborted) throw error;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
return summaryCapability;
|
||||
},
|
||||
async summarize(context, { signal } = {}) {
|
||||
return request(urls.summary, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -94,3 +94,24 @@ test('a failed probe is retried after its backoff, not on every query', async ()
|
||||
'GET /api/overpass/status',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the summary capability probe is asked once and unknown when unanswered', async () => {
|
||||
for (const [answer, expected] of [
|
||||
[() => Response.json({ configured: false }), false],
|
||||
[() => Response.json({ configured: true }), true],
|
||||
[() => new Response('missing', { status: 404 }), null],
|
||||
[() => Response.json({ configured: 'yes' }), null],
|
||||
[() => { throw new TypeError('offline'); }, null],
|
||||
]) {
|
||||
const seen = [];
|
||||
const services = createApplicationRequestServices({
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push(`${init.method || 'GET'} ${url}`);
|
||||
return answer();
|
||||
},
|
||||
});
|
||||
assert.equal(await services.summary.configured(), expected);
|
||||
assert.equal(await services.summary.configured(), expected);
|
||||
assert.deepEqual(seen, ['GET /api/openai/hud-summary/status']);
|
||||
}
|
||||
});
|
||||
@@ -411,3 +411,156 @@ test('terrain middleware retains successful chunks around a failure and retries
|
||||
);
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
test('after a 429 the terrain proxy leaves the upstream alone and logs once', async (t) => {
|
||||
isolate(t);
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('', { status: 429 });
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
const request = install(terrainHeightsProxy());
|
||||
// The retries' backoff runs on mocked time.
|
||||
let settled = false;
|
||||
const first = request('/?points=1,2').then(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the first request settles within its retry budget');
|
||||
await first;
|
||||
const afterFirst = calls;
|
||||
assert.ok(afterFirst >= 1);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /HTTP 429; pausing upstream for 60 s/);
|
||||
// New points during the pause: no upstream call, no new log line.
|
||||
const paused = await request('/?points=3,4');
|
||||
assert.equal(calls, afterFirst);
|
||||
assert.equal(paused.status, 502);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('a far-off Retry-After pauses the terrain upstream for at most 10 minutes', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let limited = true;
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
limited
|
||||
? new Response('', { status: 429, headers: { 'retry-after': '86400' } })
|
||||
: Response.json({ results: [{ ellipsoid: 100 }] }),
|
||||
);
|
||||
const request = install(terrainHeightsProxy());
|
||||
// The first request spends its retry budget on 429s, then the pause begins.
|
||||
let settled = false;
|
||||
const first = request('/?points=1,2').then((res) => {
|
||||
settled = true;
|
||||
return res;
|
||||
});
|
||||
// Bounded: the retry budget is 10 s, so 60 simulated seconds is plenty.
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the first request settles within its retry budget');
|
||||
assert.equal((await first).status, 502);
|
||||
limited = false;
|
||||
const pausedAt = Date.now();
|
||||
t.mock.timers.setTime(pausedAt + 9 * 60_000);
|
||||
assert.equal((await request('/?points=3,4')).status, 502, 'still paused');
|
||||
t.mock.timers.setTime(pausedAt + 10 * 60_000 + 1);
|
||||
assert.equal((await request('/?points=3,4')).status, 200, 'pause over');
|
||||
});
|
||||
|
||||
/** Drive mocked timers until `promise` settles; the retry budget is 10 s. */
|
||||
async function settleOnMockedTime(t, promise) {
|
||||
let settled = false;
|
||||
const tracked = promise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let step = 0; step < 60 && !settled; step += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(1_000);
|
||||
}
|
||||
assert.ok(settled, 'the request settles within its retry budget');
|
||||
return tracked;
|
||||
}
|
||||
|
||||
test('during a terrain 429 pause fully cached points, even stale ones, are still served', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
let limited = false;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return limited
|
||||
? new Response('', { status: 429, headers: { 'retry-after': '300' } })
|
||||
: Response.json({ results: [{ ellipsoid: 100 }] });
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
assert.equal((await request('/?points=5,6')).status, 200);
|
||||
// Past the 30-day TTL, so 5,6 is stale and would normally be refreshed.
|
||||
t.mock.timers.setTime(Date.now() + 31 * 24 * 3600_000);
|
||||
limited = true;
|
||||
const first = await settleOnMockedTime(t, request('/?points=1,2'));
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal(calls, 2, 'a Retry-After past the budget is not retried');
|
||||
const stale = await request('/?points=5,6');
|
||||
assert.equal(stale.status, 200);
|
||||
assert.deepEqual(json(stale).results, [{ ellipsoid: 100 }]);
|
||||
assert.equal((await request('/?points=5,6;7,8')).status, 502);
|
||||
assert.equal(calls, 2, 'no upstream call during the pause');
|
||||
});
|
||||
|
||||
test('a terrain upstream failure other than 429 does not pause later requests', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
const first = await settleOnMockedTime(t, request('/?points=1,2'));
|
||||
assert.equal(first.status, 502);
|
||||
const afterFirst = calls;
|
||||
const second = await settleOnMockedTime(t, request('/?points=3,4'));
|
||||
assert.equal(second.status, 502);
|
||||
assert.ok(calls > afterFirst, 'new points still go upstream after a 503');
|
||||
});
|
||||
|
||||
test('a shorter concurrent Retry-After never cuts a terrain 429 pause short', async (t) => {
|
||||
isolate(t);
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: 1_000_000 });
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||
calls += 1;
|
||||
const points = new URL(raw).searchParams.get('points');
|
||||
if (points.startsWith('0.'))
|
||||
return Response.json({ results: [{ ellipsoid: 1 }] });
|
||||
const retryAfter = points.startsWith('1.') ? '300' : '30';
|
||||
return new Response('', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': retryAfter },
|
||||
});
|
||||
});
|
||||
const request = install(terrainHeightsProxy());
|
||||
// Load the disk cache first so both requests below reach the upstream in
|
||||
// order: the 300 s answer lands first, the 30 s answer after it.
|
||||
assert.equal((await request('/?points=0,0')).status, 200);
|
||||
const [long, short] = await settleOnMockedTime(
|
||||
t,
|
||||
Promise.all([request('/?points=1,2'), request('/?points=3,4')]),
|
||||
);
|
||||
assert.equal(long.status, 502);
|
||||
assert.equal(short.status, 502);
|
||||
const afterBoth = calls;
|
||||
t.mock.timers.setTime(Date.now() + 120_000);
|
||||
assert.equal((await request('/?points=5,6')).status, 502);
|
||||
assert.equal(calls, afterBoth, 'still paused by the 300 s Retry-After');
|
||||
});
|
||||
@@ -729,6 +729,29 @@ test('Realtime service configuration selects compatible endpoint/model without f
|
||||
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
|
||||
});
|
||||
|
||||
test('the HUD summary status says whether a summary is configured, without calling OpenAI', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('the status probe must not reach OpenAI');
|
||||
});
|
||||
const handler = install(openAiRealtimeProxy()).get('/api/openai/hud-summary');
|
||||
for (const [key, configured] of [
|
||||
[undefined, false],
|
||||
[' ', false],
|
||||
['fixture-key', true],
|
||||
]) {
|
||||
env(t, 'OPENAI_API_KEY', key);
|
||||
const response = await request(handler, { url: '/status' });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(response.json(), { configured });
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
}
|
||||
assert.equal(
|
||||
(await request(handler, { url: '/' })).status,
|
||||
405,
|
||||
'only the status path answers GET',
|
||||
);
|
||||
});
|
||||
|
||||
test('OpenAI routes answer generically when the upstream or the request fails', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
|
||||
@@ -257,7 +257,14 @@ export const getFirePerimeters = defineTool({
|
||||
requires: ['perimeters'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const rows = (await services.perimeters.getSnapshot({ signal }))
|
||||
const snapshot =
|
||||
typeof services.perimeters.getSnapshotDetail === 'function'
|
||||
? await services.perimeters.getSnapshotDetail({ signal })
|
||||
: { rows: await services.perimeters.getSnapshot({ signal }) };
|
||||
// The proxy answers from its last snapshot, marked stale, while WFIGS is
|
||||
// unreachable.
|
||||
const stale = snapshot.stale === true;
|
||||
const rows = snapshot.rows
|
||||
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||
.filter(({ point }) => point)
|
||||
@@ -279,9 +286,13 @@ export const getFirePerimeters = defineTool({
|
||||
}))
|
||||
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
||||
summary:
|
||||
`${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}` +
|
||||
(stale ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
||||
stale,
|
||||
retrieved_at: isoTime(snapshot.fetchedAt),
|
||||
...capRows(rows, args.limit),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -871,3 +871,39 @@ test('installation names wait for the name pack, briefly', async (t) => {
|
||||
assert.equal(slow.data.names_pending, true);
|
||||
assert.match(slow.summary, /site names are still loading\)\.$/);
|
||||
});
|
||||
|
||||
test('stale fire perimeters say so, with when they were fetched', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 9, 22, 6);
|
||||
const ring = [
|
||||
[
|
||||
[-118.5, 35.2],
|
||||
[-118.4, 35.2],
|
||||
[-118.4, 35.3],
|
||||
[-118.5, 35.2],
|
||||
],
|
||||
];
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [],
|
||||
getSnapshotDetail: async () => ({
|
||||
rows: [
|
||||
{
|
||||
stableId: 'held',
|
||||
name: 'Held Fire',
|
||||
acres: 300,
|
||||
polygons: [ring],
|
||||
},
|
||||
],
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
}),
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-120, 34, -117, 36] },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 mapped wildfire perimeter in the requested box (data may be stale).',
|
||||
);
|
||||
assert.equal(result.data.stale, true);
|
||||
assert.equal(result.data.retrieved_at, new Date(fetchedAt).toISOString());
|
||||
});
|
||||
@@ -714,18 +714,24 @@ export class LayerPanel {
|
||||
});
|
||||
}
|
||||
if (feedState === 'stale') {
|
||||
// A layer drawing cached upstream data (Traffic's proxy tiles) dates the
|
||||
// row by the oldest of it, not by the load that re-served it.
|
||||
const dataAgo =
|
||||
Number(stats.oldestTileAt) > 0
|
||||
? this._timeAgo(stats.oldestTileAt)
|
||||
: ago;
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
ago: dataAgo,
|
||||
seconds: stats.retryInSec,
|
||||
});
|
||||
}
|
||||
return t('{state} · {source} · {ago}', {
|
||||
state: stateLabel,
|
||||
source,
|
||||
ago,
|
||||
ago: dataAgo,
|
||||
});
|
||||
}
|
||||
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
||||
|
||||
@@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o
|
||||
assert.equal(button.textContent, 'OFF');
|
||||
});
|
||||
|
||||
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const panel = LayerPanel.prototype;
|
||||
const layer = {
|
||||
id: 'traffic',
|
||||
name: 'Street Traffic',
|
||||
source: 'TomTom',
|
||||
enabled: true,
|
||||
stats: {
|
||||
mode: 'live',
|
||||
stale: true,
|
||||
count: 4,
|
||||
lastUpdate: Date.now(),
|
||||
oldestTileAt: Date.now() - 3 * 3600_000,
|
||||
},
|
||||
};
|
||||
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/);
|
||||
delete layer.stats.oldestTileAt;
|
||||
assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/);
|
||||
});
|
||||
|
||||
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
|
||||
const { LayerPanel } = await import('./layerPanel.js');
|
||||
const { railFixture } = await import('./railTestFixture.mjs');
|
||||
|
||||
+35
-1
@@ -112,12 +112,46 @@ const FEED_ERROR_PATTERNS = [
|
||||
],
|
||||
];
|
||||
|
||||
// Directions route failures (layers/directions/index.js), English data with
|
||||
// the route proxy's refusal or an HTTP status in them. Ahead of the feed
|
||||
// patterns, which would take "Routing failed: rate limited" for a source.
|
||||
// The route proxy's refusal texts (server/providers/places/routes.js) show
|
||||
// translated; any other text in {error} shows its catalog entry, or as given.
|
||||
const ROUTING_REFUSALS = new Set([
|
||||
msg('rate limited'),
|
||||
msg('routing service is rate limited'),
|
||||
msg('route leg too long'),
|
||||
msg('route too long'),
|
||||
msg('routing busy — too many routes at once'),
|
||||
msg('route proxy error'),
|
||||
]);
|
||||
const routingRefusal = (text) =>
|
||||
ROUTING_REFUSALS.has(text) ? t(text) : translateKnown(text);
|
||||
const ROUTING_ERROR_PATTERNS = [
|
||||
[
|
||||
/^Routing unavailable \(HTTP (\d+)\)$/,
|
||||
([, status]) => t('Routing unavailable (HTTP {status})', { status }),
|
||||
],
|
||||
[
|
||||
/^Routing failed: (.+)$/,
|
||||
([, error]) =>
|
||||
t('Routing failed: {error}', { error: routingRefusal(error) }),
|
||||
],
|
||||
[
|
||||
/^(.+) — try again in a moment$/,
|
||||
([, error]) =>
|
||||
t('{error} — try again in a moment', { error: routingRefusal(error) }),
|
||||
],
|
||||
];
|
||||
|
||||
const ERROR_PATTERNS = [...ROUTING_ERROR_PATTERNS, ...FEED_ERROR_PATTERNS];
|
||||
|
||||
/** One error text in the UI language, or null when nothing matches. */
|
||||
function feedErrorText(text) {
|
||||
if (FEED_ERRORS.has(text)) return t(text);
|
||||
const known = translateKnown(text);
|
||||
if (known !== text) return known;
|
||||
for (const [pattern, format] of FEED_ERROR_PATTERNS) {
|
||||
for (const [pattern, format] of ERROR_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
if (match) return format(match);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import { setLocale, t, translateKnown } from '../i18n/index.js';
|
||||
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
|
||||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
||||
import { createWeatherSource } from '../layers/weather/source.js';
|
||||
import {
|
||||
presentRoutingFailure,
|
||||
presentRoutingRefusal,
|
||||
} from '../layers/directions/index.js';
|
||||
import { presentRoutingFailure } from '../layers/directions/index.js';
|
||||
import { translateFeedError } from './statusText.js';
|
||||
import { AIM_AT_GROUND_MESSAGE } from '../layers/streetLevel/policy.js';
|
||||
|
||||
const NO_IMAGERY = 'No street-level imagery within 50 m of that point';
|
||||
@@ -53,9 +51,13 @@ for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the route proxy's refusals are shown translated`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
const shown = t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal('route leg too long'),
|
||||
});
|
||||
// Directions stores these in English; the Layers panel shows them
|
||||
// through translateFeedError.
|
||||
const shown = translateFeedError('Routing failed: route leg too long');
|
||||
assert.equal(
|
||||
shown,
|
||||
t('Routing failed: {error}', { error: t('route leg too long') }),
|
||||
);
|
||||
assert.doesNotMatch(shown, /route leg too long/);
|
||||
const limited = presentRoutingFailure(
|
||||
Object.assign(new Error('rate limited — try again in a moment'), {
|
||||
@@ -63,16 +65,46 @@ for (const locale of ['es', 'ru']) {
|
||||
refusal: 'rate limited',
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(limited, /rate limited|try again in a moment/);
|
||||
assert.equal(limited, 'rate limited — try again in a moment');
|
||||
assert.doesNotMatch(
|
||||
translateFeedError(limited),
|
||||
/rate limited|try again in a moment/,
|
||||
);
|
||||
const http = presentRoutingFailure(
|
||||
Object.assign(new Error('Routing unavailable (HTTP 503)'), {
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(http, 'Routing unavailable (HTTP 503)');
|
||||
assert.match(http, /503/);
|
||||
assert.equal(http, 'Routing unavailable (HTTP 503)');
|
||||
assert.notEqual(translateFeedError(http), http);
|
||||
assert.match(translateFeedError(http), /503/);
|
||||
// A text the proxy never sends is shown as given.
|
||||
assert.equal(presentRoutingRefusal('something else'), 'something else');
|
||||
assert.equal(
|
||||
translateFeedError('Routing failed: something else'),
|
||||
t('Routing failed: {error}', { error: 'something else' }),
|
||||
);
|
||||
// Every refusal the proxy sends (server/providers/places/routes.js),
|
||||
// and "rate limited" after "Routing failed:", which the feed pattern
|
||||
// "<source> rate limited" would otherwise take.
|
||||
for (const [english, shown] of [
|
||||
[
|
||||
'routing busy — too many routes at once — try again in a moment',
|
||||
t('{error} — try again in a moment', {
|
||||
error: t('routing busy — too many routes at once'),
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Routing failed: route proxy error',
|
||||
t('Routing failed: {error}', { error: t('route proxy error') }),
|
||||
],
|
||||
[
|
||||
'Routing failed: rate limited',
|
||||
t('Routing failed: {error}', { error: t('rate limited') }),
|
||||
],
|
||||
]) {
|
||||
assert.equal(translateFeedError(english), shown);
|
||||
assert.doesNotMatch(shown, /routing busy|route proxy|rate limited/);
|
||||
}
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
|
||||
+81
-4
@@ -12,6 +12,11 @@ import { msg } from '../i18n/index.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
import { greatCircleKm } from '../geoDistance.js';
|
||||
import {
|
||||
hudViewportSamples,
|
||||
placeLookupView,
|
||||
placeLookupViewChanged,
|
||||
} from '../hudPlaceLookup.js';
|
||||
import {
|
||||
findPoiByName,
|
||||
flyToGlobeView,
|
||||
@@ -2692,13 +2697,20 @@ function runManagedVoiceNavigation(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Place labels for the HUD's passive summary. It geocodes the view center
|
||||
* and one more point. While the view still shows about the same area (see
|
||||
* hudPlaceLookup.js) it answers for the points the last lookup asked about
|
||||
* instead, so a settled camera does not buy a new set of Google requests for
|
||||
* every small pan; those points are cache hits, and only what the last
|
||||
* lookup did not get (a timeout, a failure, an empty answer) is asked again.
|
||||
* `cachedOnly` reads the shared caches and makes no request.
|
||||
*/
|
||||
export async function getBasemapLabelContext(
|
||||
viewer,
|
||||
service = defaultGeospatial,
|
||||
{ cachedOnly = false } = {},
|
||||
{ cachedOnly = false, previous = null } = {},
|
||||
) {
|
||||
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
|
||||
const samples = sampleViewportCartographics(viewer);
|
||||
const cameraHeightM = viewer.camera.positionCartographic.height;
|
||||
const target = getViewTargetCartographic(viewer);
|
||||
if (!target) {
|
||||
@@ -2706,11 +2718,77 @@ export async function getBasemapLabelContext(
|
||||
placeLabels: [],
|
||||
streetLabels: [],
|
||||
nearbyPlaceLabels: [],
|
||||
view: null,
|
||||
lookup: null,
|
||||
};
|
||||
}
|
||||
|
||||
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
|
||||
const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6));
|
||||
const view = placeLookupView({
|
||||
latitude,
|
||||
longitude,
|
||||
rangeM: viewTargetRangeM(viewer, target, cameraHeightM),
|
||||
fovRad: viewer.camera.frustum?.fov,
|
||||
aspectRatio: viewer.camera.frustum?.aspectRatio,
|
||||
tier: basemapLookupTier(cameraHeightM),
|
||||
});
|
||||
if (
|
||||
!cachedOnly &&
|
||||
previous?.lookup &&
|
||||
!placeLookupViewChanged(previous.view, view)
|
||||
) {
|
||||
// The same points again: what the last lookup got is a cache hit, and
|
||||
// only what it did not (a timeout, a failure, an empty answer) is asked.
|
||||
const labels = await basemapLabelsAt(previous.lookup, service, {
|
||||
cachedOnly: false,
|
||||
});
|
||||
return { ...labels, view: previous.view, lookup: previous.lookup };
|
||||
}
|
||||
const samples = sampleViewportCartographics(viewer);
|
||||
const lookup = {
|
||||
latitude,
|
||||
longitude,
|
||||
cameraHeightM,
|
||||
// Cache reads are free, so a cached-only host keeps every sample.
|
||||
samples: cachedOnly
|
||||
? samples
|
||||
: hudViewportSamples(samples, shouldReverseGeocode(cameraHeightM)),
|
||||
};
|
||||
return {
|
||||
...(await basemapLabelsAt(lookup, service, { cachedOnly })),
|
||||
view,
|
||||
lookup,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which of the height-gated lookups a camera height calls for. */
|
||||
function basemapLookupTier(cameraHeightM) {
|
||||
return [
|
||||
shouldReverseGeocode(cameraHeightM),
|
||||
shouldReverseGeocodeViewport(cameraHeightM) && cameraHeightM > 10000,
|
||||
shouldFetchNearbyPlaces(cameraHeightM) &&
|
||||
nearbyPlacesRadiusM(cameraHeightM),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/** Camera distance to the view target, or the camera height without one. */
|
||||
function viewTargetRangeM(viewer, target, cameraHeightM) {
|
||||
const position = viewer.camera.positionWC;
|
||||
if (!position) return cameraHeightM;
|
||||
const range = Cesium.Cartesian3.distance(
|
||||
position,
|
||||
Cesium.Cartographic.toCartesian(target),
|
||||
);
|
||||
return Number.isFinite(range) ? range : cameraHeightM;
|
||||
}
|
||||
|
||||
async function basemapLabelsAt(
|
||||
{ latitude, longitude, cameraHeightM, samples },
|
||||
service,
|
||||
{ cachedOnly },
|
||||
) {
|
||||
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
|
||||
const cachedViewportPlaces = viewportPlacesFromCache(
|
||||
samples,
|
||||
cameraHeightM,
|
||||
@@ -2748,7 +2826,6 @@ export async function getBasemapLabelContext(
|
||||
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null),
|
||||
resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []),
|
||||
]);
|
||||
|
||||
return {
|
||||
placeLabels: uniqueStrings([
|
||||
place?.formattedAddress,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// The HUD's passive place lookup spends paid Google requests on its own, so
|
||||
// it must look up the view center and one more point, and only again once the
|
||||
// view shows a different area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { getBasemapLabelContext } from './gevActions.js';
|
||||
import {
|
||||
hudViewportSamples,
|
||||
placeLookupView,
|
||||
placeLookupViewChanged,
|
||||
} from '../hudPlaceLookup.js';
|
||||
|
||||
globalThis.window = globalThis.window || { setTimeout, clearTimeout };
|
||||
|
||||
/**
|
||||
* A looking-down camera `heightM` above the ellipsoid. Ellipsoid picks map
|
||||
* screen pixels linearly to degrees; the view target sits on terrain
|
||||
* `groundM` high under the camera.
|
||||
*/
|
||||
function createViewer({ latitude, longitude, heightM, groundM = 0 }) {
|
||||
const state = { latitude, longitude, heightM, groundM };
|
||||
const canvas = { clientWidth: 1200, clientHeight: 800 };
|
||||
const degreesPerPixel = () => state.heightM / 111_000 / canvas.clientHeight;
|
||||
const camera = {
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
frustum: { fov: Math.PI / 3, aspectRatio: 1.5 },
|
||||
pickEllipsoid(point) {
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
state.longitude +
|
||||
(point.x - canvas.clientWidth / 2) * degreesPerPixel(),
|
||||
state.latitude -
|
||||
(point.y - canvas.clientHeight / 2) * degreesPerPixel(),
|
||||
);
|
||||
},
|
||||
};
|
||||
Object.defineProperties(camera, {
|
||||
positionCartographic: {
|
||||
get: () =>
|
||||
Cesium.Cartographic.fromDegrees(
|
||||
state.longitude,
|
||||
state.latitude,
|
||||
state.heightM,
|
||||
),
|
||||
},
|
||||
positionWC: {
|
||||
get: () =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
state.longitude,
|
||||
state.latitude,
|
||||
state.heightM,
|
||||
),
|
||||
},
|
||||
});
|
||||
const scene = {
|
||||
canvas,
|
||||
pickPositionSupported: true,
|
||||
pickPosition: () =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
state.longitude,
|
||||
state.latitude,
|
||||
state.groundM,
|
||||
),
|
||||
};
|
||||
return { viewer: { camera, scene }, state, degreesPerPixel };
|
||||
}
|
||||
|
||||
/** Answers every lookup with a label naming the point it was asked about. */
|
||||
function createService() {
|
||||
const calls = { reverse: 0, nearby: 0 };
|
||||
return {
|
||||
calls,
|
||||
async reverseGeocode(latitude, longitude) {
|
||||
calls.reverse += 1;
|
||||
const point = `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
|
||||
return { formattedAddress: `Place ${point}`, labels: [`Area ${point}`] };
|
||||
},
|
||||
async nearby() {
|
||||
calls.nearby += 1;
|
||||
return [{ name: `Nearby ${calls.nearby}` }];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a view counts as new after a quarter-width move or a doubled distance', () => {
|
||||
const view = placeLookupView({ latitude: 30, longitude: -97, rangeM: 1000 });
|
||||
// A 60° field of view at 1 km shows about 1155 m across.
|
||||
assert.ok(Math.abs(view.spanM - 1154.7) < 1);
|
||||
const near = placeLookupView({
|
||||
latitude: 30.002,
|
||||
longitude: -97,
|
||||
rangeM: 1100,
|
||||
});
|
||||
assert.equal(
|
||||
placeLookupViewChanged(view, near),
|
||||
false,
|
||||
'~220 m is under a quarter width',
|
||||
);
|
||||
const far = placeLookupView({
|
||||
latitude: 30.003,
|
||||
longitude: -97,
|
||||
rangeM: 1000,
|
||||
});
|
||||
assert.equal(
|
||||
placeLookupViewChanged(view, far),
|
||||
true,
|
||||
'~330 m is over a quarter width',
|
||||
);
|
||||
const closer = placeLookupView({ latitude: 30, longitude: -97, rangeM: 500 });
|
||||
assert.equal(placeLookupViewChanged(view, closer), true);
|
||||
const farther = placeLookupView({
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
rangeM: 2000,
|
||||
});
|
||||
assert.equal(placeLookupViewChanged(view, farther), true);
|
||||
assert.equal(placeLookupViewChanged(null, view), true);
|
||||
// The antimeridian is a short hop, not a world away.
|
||||
const west = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: 179.9999,
|
||||
rangeM: 1000,
|
||||
});
|
||||
const east = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: -179.9999,
|
||||
rangeM: 1000,
|
||||
});
|
||||
assert.equal(placeLookupViewChanged(west, east), false);
|
||||
});
|
||||
|
||||
test('a portrait screen measures its narrower width', () => {
|
||||
const landscape = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
rangeM: 1000,
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
const portrait = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
rangeM: 1000,
|
||||
aspectRatio: 0.5,
|
||||
});
|
||||
assert.ok(Math.abs(landscape.spanM - 1154.7) < 1);
|
||||
assert.ok(Math.abs(portrait.spanM - 577.4) < 1);
|
||||
});
|
||||
|
||||
test('a view in another height tier is new however close it is', () => {
|
||||
const a = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
rangeM: 1000,
|
||||
tier: 'x',
|
||||
});
|
||||
const b = placeLookupView({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
rangeM: 1000,
|
||||
tier: 'y',
|
||||
});
|
||||
assert.equal(placeLookupViewChanged(a, b), true);
|
||||
});
|
||||
|
||||
test('the HUD geocodes one viewport point besides the center', () => {
|
||||
const samples = [{ i: 0 }, { i: 1 }, { i: 2 }];
|
||||
assert.deepEqual(hudViewportSamples(samples, true), [{ i: 1 }]);
|
||||
assert.deepEqual(hudViewportSamples(samples, false), [{ i: 0 }, { i: 1 }]);
|
||||
assert.deepEqual(hudViewportSamples([], true), []);
|
||||
});
|
||||
|
||||
test('a regional view spends two reverse geocodes and a nearby search', async () => {
|
||||
const { viewer } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 20_000,
|
||||
});
|
||||
const service = createService();
|
||||
const labels = await getBasemapLabelContext(viewer, service);
|
||||
// Before: the center plus three viewport samples (the first of them the
|
||||
// center again, which shares its cache key in this stub).
|
||||
assert.deepEqual(service.calls, { reverse: 2, nearby: 1 });
|
||||
assert.ok(labels.placeLabels.length > 0);
|
||||
});
|
||||
|
||||
test('a small pan reuses the last lookup; a real move looks up again', async () => {
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 600,
|
||||
});
|
||||
const service = createService();
|
||||
const first = await getBasemapLabelContext(viewer, service);
|
||||
assert.deepEqual(service.calls, { reverse: 1, nearby: 1 });
|
||||
|
||||
state.latitude += 0.0005; // ~55 m at 600 m height
|
||||
const panned = await getBasemapLabelContext(viewer, service, {
|
||||
previous: first,
|
||||
});
|
||||
assert.deepEqual(
|
||||
panned.placeLabels,
|
||||
first.placeLabels,
|
||||
'the same area keeps its labels',
|
||||
);
|
||||
assert.deepEqual(service.calls, { reverse: 1, nearby: 1 });
|
||||
|
||||
state.latitude += 0.002; // ~280 m from the first lookup
|
||||
const moved = await getBasemapLabelContext(viewer, service, {
|
||||
previous: panned,
|
||||
});
|
||||
assert.notDeepEqual(moved.placeLabels, first.placeLabels);
|
||||
assert.deepEqual(service.calls, { reverse: 2, nearby: 2 });
|
||||
});
|
||||
|
||||
test('over high terrain the visible width is measured from the ground', async () => {
|
||||
// 100 m above a 3000 m plateau shows ~115 m across, not the ~3.6 km a
|
||||
// 3100 m ellipsoid height would suggest.
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 3100,
|
||||
groundM: 3000,
|
||||
});
|
||||
const service = createService();
|
||||
const first = await getBasemapLabelContext(viewer, service);
|
||||
state.latitude += 0.0045; // ~500 m
|
||||
await getBasemapLabelContext(viewer, service, { previous: first });
|
||||
assert.equal(service.calls.reverse, 2);
|
||||
});
|
||||
|
||||
test('crossing into the Nearby Places height looks up again', async () => {
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 30_000,
|
||||
});
|
||||
const service = createService();
|
||||
const high = await getBasemapLabelContext(viewer, service);
|
||||
assert.equal(service.calls.nearby, 0);
|
||||
state.heightM = 20_000; // a 1.5× zoom, under the distance rule
|
||||
const low = await getBasemapLabelContext(viewer, service, { previous: high });
|
||||
assert.equal(service.calls.nearby, 1);
|
||||
assert.ok(low.nearbyPlaceLabels.length > 0);
|
||||
});
|
||||
|
||||
test('a lookup that timed out is completed from the cache, not frozen', async () => {
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 600,
|
||||
});
|
||||
const service = createService();
|
||||
let answer;
|
||||
service.reverseGeocode = () => {
|
||||
service.calls.reverse += 1;
|
||||
return new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
};
|
||||
// Expire the 1.5 s wait at once.
|
||||
const { setTimeout: wait } = globalThis.window;
|
||||
globalThis.window.setTimeout = (fn) => wait(fn, 0);
|
||||
let first;
|
||||
try {
|
||||
first = await getBasemapLabelContext(viewer, service);
|
||||
} finally {
|
||||
globalThis.window.setTimeout = wait;
|
||||
}
|
||||
assert.deepEqual(first.placeLabels, []);
|
||||
assert.ok(
|
||||
first.nearbyPlaceLabels.length > 0,
|
||||
'the nearby answer arrived in time',
|
||||
);
|
||||
|
||||
answer({ formattedAddress: 'Late place', labels: [] });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
state.latitude += 0.0005;
|
||||
const next = await getBasemapLabelContext(viewer, service, {
|
||||
previous: first,
|
||||
});
|
||||
assert.deepEqual(next.placeLabels, ['Late place']);
|
||||
assert.deepEqual(
|
||||
service.calls,
|
||||
{ reverse: 1, nearby: 1 },
|
||||
'the late answer is a cache hit',
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty answer is asked again rather than held', async () => {
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 600,
|
||||
});
|
||||
const service = createService();
|
||||
service.reverseGeocode = async () => {
|
||||
service.calls.reverse += 1;
|
||||
return null;
|
||||
};
|
||||
const empty = await getBasemapLabelContext(viewer, service);
|
||||
state.latitude += 0.0005;
|
||||
await getBasemapLabelContext(viewer, service, { previous: empty });
|
||||
assert.deepEqual(
|
||||
service.calls,
|
||||
{ reverse: 2, nearby: 1 },
|
||||
'the cached nearby answer is not asked again',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed lookup is asked again once the provider recovers', async () => {
|
||||
const { viewer, state } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 600,
|
||||
});
|
||||
const service = createService();
|
||||
const working = service.reverseGeocode;
|
||||
service.reverseGeocode = async () => {
|
||||
service.calls.reverse += 1;
|
||||
throw new Error('503');
|
||||
};
|
||||
const failed = await getBasemapLabelContext(viewer, service);
|
||||
assert.deepEqual(failed.placeLabels, []);
|
||||
|
||||
service.reverseGeocode = working;
|
||||
state.latitude += 0.0005;
|
||||
const recovered = await getBasemapLabelContext(viewer, service, {
|
||||
previous: failed,
|
||||
});
|
||||
assert.ok(recovered.placeLabels.length > 0);
|
||||
assert.deepEqual(
|
||||
service.calls,
|
||||
{ reverse: 2, nearby: 1 },
|
||||
'only the failed geocode is asked again',
|
||||
);
|
||||
});
|
||||
|
||||
test('cached-only lookups read every cached viewport sample and never request', async () => {
|
||||
const { viewer, state, degreesPerPixel } = createViewer({
|
||||
latitude: 30.26,
|
||||
longitude: -97.74,
|
||||
heightM: 20_000,
|
||||
});
|
||||
const service = createService();
|
||||
// Geocode view A's upper-left sample (screen x = 300).
|
||||
const a = await getBasemapLabelContext(viewer, service);
|
||||
const cachedLabel = a.placeLabels.find(
|
||||
(label) => label.startsWith('Area') && label !== 'Area 30.2600,-97.7400',
|
||||
);
|
||||
assert.ok(cachedLabel, 'the upper-left sample was geocoded');
|
||||
// View B puts that same point at its upper-right sample (screen x = 900),
|
||||
// which only a read of every sample finds.
|
||||
state.longitude -= 600 * degreesPerPixel();
|
||||
const before = { ...service.calls };
|
||||
const b = await getBasemapLabelContext(viewer, service, { cachedOnly: true });
|
||||
assert.ok(b.placeLabels.includes(cachedLabel));
|
||||
assert.deepEqual(service.calls, before);
|
||||
});
|
||||
Reference in new issue
Block a user