Merge branch 'main' into i18n-spanish

This commit is contained in:
Meir Michanie authored and GitHub committed 2026-10-15 08:57:13 +02:00
commit 343a5df057
53 files changed
+968 -151

No files matched your search

+24
View File
@@ -37,6 +37,30 @@
@meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is
not marked at all, so the whole interface, including surfaces added after
the original extraction, is translated (1,679 messages).
- Street Level counts unique tiles before ranking so overlapping dateline halves
in polar views do not waste slots in the 16-tile overview budget.
- Directions no longer shows a truncation notice for a route with exactly 200
turns, and a roundabout at the 200th turn keeps its exit distance.
- Weather records keep missing conditions unknown and refuse summaries without a measured temperature.
- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane.
- Director timeline seeks cross the dateline along the short longitude arc.
- Map state updates stop delivering to subscribers removed during the same notification.
- Director assets cancelled during the final stream read are discarded with reader cleanup.
- Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range.
- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid.
- Frequently reused terrain batches retain their cached heights ahead of unused cells.
- Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or
boolean programmatic box edges instead of interpreting them as zero coordinates.
- Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots.
+36 -8
View File
@@ -73,6 +73,10 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927).
## Tools and local MCP server — October 1, 2026
Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units.
Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row.
`gods-eye-view/tools` defines queries that answer questions from the app's data,
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
@@ -394,11 +398,18 @@ and geographic placement remain distinct; Stop/replacement releases resources.
Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene
content, assets and credit remain unchanged.
The asset directory source rejects with the caller's abort reason if
cancellation arrives during any read, including the final one, or while the
reader is being released.
Director version-4 documents add named geographic anchors and optional explicit
camera moves with easing, duration and holds. Playback and seek share one
coordinate sampler; navigation/manual input revokes authored motion and pending
holds. Anchor heights and explicit endpoints require the WGS84 ellipsoid
reference. Legacy projects retain their ordinary flights, content and edits.
Seeking between ordinary camera poses uses the shortest longitude arc across the
dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles.
See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input
@@ -1193,11 +1204,11 @@ entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
visits to one place share a key but does not bound how many distinct places a
session visits, and the cache previously had no eviction, so a long session
retained every coordinate it ever resolved. Eviction is least-recently-used:
a consumer read promotes its entry, so the cell being watched is not dropped for
having been resolved early. The bound sits far above a city-scale session, so
a consumer read, including a warm hit in a batch lookup, promotes its entry, so
the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so
normal use never evicts and issues no additional proxy requests. A batch's
results are assembled from what that call resolved, so eviction during a large
batch cannot report a just-resolved point as unresolved. Re:Earth and
results are assembled from what that call resolved or found warm, so eviction
during a large batch cannot report one of its points as unresolved. Re:Earth and
geoid-fallback entry semantics, the fallback cooldown and the abort-time flush
are unchanged.
@@ -1424,6 +1435,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
## FIRMS source status
FIRMS ingestion and adaptation require explicit finite coordinates within WGS84 ranges; genuine zero coordinates remain valid and admitted detection indices stay sequential.
The FIRMS proxy records source success after appending its rows. If aggregation
throws, that source reports failure without a contradictory success entry.
The existing per-record append continues to support large feeds; sequential
@@ -3154,6 +3167,10 @@ sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Mapillary coverage ranks unique tile addresses before applying the 16-tile
overview budget. Overlapping dateline halves in a polar view therefore do not
waste overview slots on the same tile; loading already skips duplicate keys.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
@@ -3421,9 +3438,10 @@ rate limit from the routing service is reported as one instead of as "no route
found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit /
road name into one plain-English sentence per decision, folding
`exit roundabout` steps into the roundabout they leave, and caps one route at
200 maneuvers — a route past that cap is reported as cut off, in the row
summary and as the last line of the list, because the arrival step is among
the ones dropped.
200 maneuvers — a route with more is reported as cut off, in the row summary
and as the last line of the list, because the arrival step is among the ones
dropped. A route of exactly 200 is complete, and a roundabout exit after the
200th keeps folding into it.
A step's card opens from its dot on the globe or from its line in the list, and
the list covers every step including departure and arrival, which have no dot.
@@ -4470,6 +4488,11 @@ are omitted rather than framing the wrong part of the globe.
### Recent Imagery (September 2026)
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
reject missing, blank or boolean edges instead of interpreting them as zero
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
pins and share links already supply complete boxes.
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
@@ -4478,7 +4501,7 @@ are omitted rather than framing the wrong part of the globe.
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`.
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect across the transitions within 1 px; that measurement is the real layout-shift check.
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
### 3D Aircraft + Tracking (June 2026)
@@ -4659,6 +4682,7 @@ are omitted rather than framing the wrong part of the globe.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence.
- **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition.
- **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown.
- **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material.
- **Aircraft course slew:** civilian and military 3D models retain the 60°/s course limiter, but each rendered frame can consume at most 250 ms of accumulated slew time. A long tile/render stall therefore catches up over multiple visible frames instead of turning one delayed frame into a heading snap.
@@ -5227,6 +5251,10 @@ falls back to OSM on construction failure or two active tile failures, and
credits follow the source actually displayed. Terrain remains lazy while the
photoreal globe is hidden.
Map-stack subscribers hear each settled activation once. A subscriber removed
during a delivery, or every subscriber once the controller is destroyed, gets
no further callback from it, and a destroyed controller accepts no new ones.
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied
+2
View File
@@ -934,6 +934,8 @@
"scripts/qa-street-level.mjs",
"src/tooling/streetLevelQa.test.mjs",
"scripts/qa-browserEvidence.mjs",
"scripts/qa-chrome.mjs",
"src/qaChrome.test.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"scripts/fixtures/street-level/kartaView.mjs",
"src/layers/streetLevel/filter.test.mjs",
+2 -2
View File
@@ -1,6 +1,7 @@
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { chromeExecutablePath } from './qa-chrome.mjs';
import {
planeSupportPoints,
poseHash,
@@ -389,8 +390,7 @@ async function main() {
const { default: puppeteer } = await import('puppeteer');
browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
protocolTimeout: 600000,
args: [
'--no-sandbox',
+4 -1
View File
@@ -6,6 +6,7 @@
*/
import puppeteer from 'puppeteer';
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
@@ -65,7 +66,9 @@ try {
requestAnimationFrame(tick);
}));
await new Promise((r) => setTimeout(r, 1_000));
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
const path = fileURLToPath(
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
);
await page.screenshot({ path });
console.log(`saved ${path}`);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The browser for gates that drive an installed Chrome. These gates were
* written against macOS Google Chrome, which Windows and Linux do not have.
*/
import { existsSync } from 'node:fs';
export const MACOS_CHROME =
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
/**
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
*/
export function chromeExecutablePath({
env = process.env,
exists = existsSync,
} = {}) {
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
}
+2 -2
View File
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+2 -2
View File
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
+6 -2
View File
@@ -230,16 +230,20 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
if (!FIXTURES && !status?.hasKey) {
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+36 -32
View File
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
const FILES = {
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'index.html'),
css: path.join(ROOT, 'style.css'),
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui.js'),
voiceDescriptions: path.join(
ROOT,
'server/providers/openai/toolDescriptions.js',
),
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
};
@@ -63,14 +67,14 @@ const MUTATIONS = [
{
defect: 'the session flag is never read, so it re-nags on every reload',
file: 'module',
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
to: '// session check removed',
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
to: ' // session check removed',
},
{
defect: 'the durable flag is never read, so the checkbox does nothing',
file: 'module',
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
to: '// durable check removed',
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
to: ' // durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
@@ -105,7 +109,7 @@ const MUTATIONS = [
{
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
file: 'module',
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
},
{
@@ -131,8 +135,8 @@ const MUTATIONS = [
{
defect: 'nothing watches for another surface taking the screen',
file: 'module',
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
to: '/* observer never attached */',
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
to: ' /* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
@@ -155,8 +159,8 @@ const MUTATIONS = [
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
to: ' && root.getClientRects().length > 0;',
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
to: ' root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
@@ -174,9 +178,9 @@ const MUTATIONS = [
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
file: 'ui',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
file: 'radio',
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
@@ -234,7 +238,7 @@ const MUTATIONS = [
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: " environmental: Object.freeze({",
from: ' environmental: Object.freeze({',
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
@@ -326,8 +330,8 @@ const MUTATIONS = [
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
to: '// panel reveal removed',
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
to: ' // panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
@@ -340,8 +344,8 @@ const MUTATIONS = [
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
to: 'for (const button of buttons) button.disabled = next;',
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
to: ' for (const button of buttons) button.disabled = next;',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
@@ -370,8 +374,8 @@ const MUTATIONS = [
{
defect: 'the card stays clickable through its fade-out (double-launch)',
file: 'css',
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
},
{
defect: 'the launcher survives Clean-UI and recording mode',
@@ -403,8 +407,8 @@ const MUTATIONS = [
// those instead and prove nothing about this feature.
defect: "the launcher's reduced-motion opt-out is dropped",
file: 'css',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
to: ' .first-run-nothing { transition: none; }',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
to: ' .first-run-nothing {\n transition: none;\n }',
},
// ── Markup and startup ordering ───────────────────────────────────────────
@@ -442,21 +446,21 @@ const MUTATIONS = [
defect: 'the launcher is revealed before the loading cover yields',
file: 'main',
from: " loadingScreen.classList.add('hidden');",
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'initFirstRunExperience({ styleManager, dataManager });',
to: 'initFirstRunExperience({ styleManager });',
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
to: 'firstRun = initializeWelcome?.({ styleManager });',
},
// ── Voice: schema must not drift ──────────────────────────────────────────
{
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
file: 'voiceTools',
from: " 'earthquakes',\n 'satellites',",
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
file: 'voiceDescriptions',
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
+5 -5
View File
@@ -26,7 +26,7 @@ const TESTS = [
];
const FLIGHTS = 'src/layers/flights/motion.js';
const FLOOR = 'src/data/groundFloor.js';
const FLOOR = 'src/services/groundFloor.js';
const ALT = 'src/data/renderAltitude.js';
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
@@ -133,8 +133,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
from: ' if (lowest == null || h < lowest) lowest = h;',
to: ' if (lowest == null || h > lowest) lowest = h;',
},
],
},
@@ -143,8 +143,8 @@ const MUTATIONS = [
edits: [
{
file: FLOOR,
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
to: ' return resolved >= 1 ? lowest : null;',
},
],
},
+10 -10
View File
@@ -35,17 +35,17 @@ const MUTATIONS = [
},
{
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
},
{
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
to: ' // branch removed',
},
{
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
to: ' state.floorM = carto.height;',
},
{
@@ -55,12 +55,12 @@ const MUTATIONS = [
},
{
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
to: ' if (false) {',
},
{
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
to: ' if (false) {',
},
{
@@ -90,8 +90,8 @@ const MUTATIONS = [
},
{
defect: 'the mesh probe fires every arming frame instead of once per route',
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
},
{
defect: 'one warm cell resolves a corridor whose other cells are unknown',
@@ -105,13 +105,13 @@ const MUTATIONS = [
},
{
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
},
{
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
},
{
defect: 'the OS reduced-motion preference is never consulted',
+27 -12
View File
@@ -12,16 +12,22 @@
* Run: start the app (`./scripts/dev-fresh.sh`) and
* node scripts/qa-recent-imagery.mjs
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
* network access to NASA the run skips with exit code 0.
* qa-shots/recent-imagery), QA_HEADFUL=1.
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
* Every wait is bounded. Without NASA access the run skips with code 0.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
const HEADFUL = process.env.QA_HEADFUL === '1';
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
const STEP_TIMEOUT = 20_000;
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
const PANEL = '#recent-imagery-panel';
@@ -49,8 +55,7 @@ const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
}
: {}),
args: [
@@ -308,15 +313,21 @@ const CONTROLS = {
};
const measure = () =>
page.evaluate((controls) => {
const panel = document.getElementById('recent-imagery-panel');
const body = document.getElementById('recent-imagery-panel-body');
const panelRect = panel?.getBoundingClientRect();
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
for (const [name, selector] of Object.entries(controls)) {
const rect = document.querySelector(selector)?.getBoundingClientRect();
out.rects[name] = rect
? [rect.left, rect.top, rect.width, rect.height].map(
(v) => Math.round(v * 10) / 10,
)
: null;
out.rects[name] =
rect && panelRect
? [
rect.left - panelRect.left,
rect.top - panelRect.top,
rect.width,
rect.height,
].map((v) => Math.round(v * 10) / 10)
: null;
}
return out;
}, CONTROLS);
@@ -372,6 +383,9 @@ try {
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
const photoreal = initialStack === 'photoreal';
console.log(`initial map stack: ${initialStack}`);
console.log(
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
);
const onPhotorealOnly = (name, passed, detail) =>
photoreal
? check(name, passed, detail)
@@ -1042,13 +1056,13 @@ try {
) <=
target * 0.01,
fitHeight,
2_000,
ZOOM_TIMEOUT_MS,
);
const zoomedHeight = await page.evaluate(
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
);
check(
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
zoomed,
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
);
@@ -1258,7 +1272,8 @@ try {
);
await wait(100);
}
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
const tail = (key) =>
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
return {
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
+6 -2
View File
@@ -139,15 +139,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -145,15 +145,19 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
process.exit(2);
process.exitCode = 2;
return;
}
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!status?.hasKey) {
console.error('Server has no TomTom key — the A/B needs live flow.');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(OUT_DIR, { recursive: true });
+6 -2
View File
@@ -113,17 +113,21 @@ async function main() {
try {
const res = await fetch(APP_URL);
// Only the status matters; an unread body can hold the socket open.
await res.body?.cancel();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exit(2);
process.exitCode = 2;
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
+2 -2
View File
@@ -1,11 +1,11 @@
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) {
const browser = await puppeteer.launch({
headless: true,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: ['--no-sandbox', '--disable-background-timer-throttling'],
});
try {
+2 -2
View File
@@ -1,5 +1,6 @@
/** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
+2 -2
View File
@@ -36,6 +36,7 @@ import {
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js';
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
import { transitModeTier } from '../src/data/transitPresetStyle.js';
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({
headless: false,
protocolTimeout: 150000,
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
+9 -7
View File
@@ -142,9 +142,11 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde
const rows = Array.from({ length: 15000 }, (_, i) =>
row(now, `vehicle-${i}`),
);
const start = performance.now();
// Main-thread CPU: excludes scheduler waits and parallel GC helper threads.
const start = process.threadCpuUsage();
h.ingest(feed, rows, now);
times.push(performance.now() - start);
const usage = process.threadCpuUsage(start);
times.push((usage.user + usage.system) / 1000);
}
assert.equal(h.diagnostics().keys, 15000);
for (let i = 0; i < 15000; i++) {
@@ -154,12 +156,12 @@ test('three identical-order 15000-vehicle snapshots retain paths within a bounde
}
assert.ok(h.diagnostics().allocatedBytes < 16 * 1024 * 1024);
t.diagnostic(
`15000-vehicle ingestion ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
`15000-vehicle ingestion CPU ms: ${times.map((n) => n.toFixed(1)).join('/')}`,
);
// A ceiling that says "not quadratic", not a benchmark: 15,000 rows in
// well under two seconds on a loaded laptop or a slow CI runner. The
// per-run figure above is the diagnostic to read.
assert.ok(Math.max(...times) < 2000, `ingestion ms: ${times}`);
// CPU time measures ingestion work without counting scheduler delays on
// loaded runners. Keep the two-second ceiling as a guard against quadratic
// work, not a benchmark; the per-poll figures above show any slowdown.
assert.ok(Math.max(...times) < 2000, `ingestion CPU ms: ${times}`);
});
test('snapshot capacity pressure preserves members not yet visited and discloses recreated history', (t) => {
+5 -2
View File
@@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseFirmsCsv } from './firmsCsv.js';
import { adaptFirmsRecords } from './firmsAdapt.js';
test('missing positions do not become fire detections at zero coordinates', () => {
const csv =
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[0, 0],
[-90, -180],
],
);
const records = [
{ lat: null, lon: 1 },
{ lat: '', lon: 1 },
{ lat: false, lon: 1 },
{ lat: 95, lon: 1 },
{ lat: 1, lon: 181 },
{ lat: '0', lon: 0 },
{ lat: -90, lon: -180 },
];
assert.deepEqual(
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
[
[0, 0, 0],
[1, -90, -180],
],
);
});
test('both axes keep their exact bounds and drop values just past them', () => {
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
const csv = [
'latitude,longitude,acq_date,acq_time,confidence,frp',
row(90, 180),
row(-90, -180),
row(90.000001, 0),
row(-90.000001, 0),
row(0, 180.000001),
row(0, -180.000001),
row(' ', 5),
row(5, '\t'),
].join('\n');
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
const kept = adaptFirmsRecords([
{ lat: 90, lon: 180 },
{ lat: '-90', lon: '-180' },
{ lat: -90.000001, lon: 0 },
{ lat: 0, lon: -180.000001 },
{ lat: 5, lon: ' ' },
{ lat: 5, lon: undefined },
{ lat: [5], lon: 5 },
]);
assert.deepEqual(
kept.map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
});
+19 -3
View File
@@ -7,8 +7,8 @@
*/
/**
* Map proxy fire records into internal fire records. Records with
* non-finite coordinates are skipped; `index` is the post-skip position
* Map proxy fire records into internal fire records. Records with missing,
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
* (it keys pick ids and context-store ids, so it must stay sequential).
* `contextEntity`/`position` start null and are lazily filled by the layer.
* @param {?Array<Object>} records - /api/firms `fires` array.
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
for (const record of records) {
const lat = Number(record?.lat);
const lon = Number(record?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (
!hasCoordinateValue(record?.lat) ||
!hasCoordinateValue(record?.lon) ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
fires.push({
index: fires.length,
lat,
@@ -110,3 +118,11 @@ function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
function hasCoordinateValue(value) {
return (
typeof value === 'number' ||
(typeof value === 'string' && value.trim() !== '')
);
}
+11 -1
View File
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
if (parts.length < header.length) continue; // malformed row — skip
const lat = Number(parts[iLat]);
const lon = Number(parts[iLon]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Blank or out-of-range positions are dropped, never read as 0 (see
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
if (
!parts[iLat].trim() ||
!parts[iLon].trim() ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
records.push({
lat,
+10 -4
View File
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
removeGuard();
// The newest intent must be able to abort the hung compensation and run.
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
let giveUpTimer;
const finalOn = await Promise.race([
pendingFinalOn,
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
releaseFirstDisable();
const blockedOn = await pendingBlockedOn;
let giveUpTimer;
const reentrant = await Promise.race([
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
await pendingOff;
await mgr.waitForLayerSettled('flights');
unsubscribe();
+15 -7
View File
@@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
return articles;
}
/** A reported number, or null when the observation is missing or not numeric. */
function observedNumber(value) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
if (!current || observedNumber(current.temperature_2m) == null) return null;
const numberOrNull = observedNumber;
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw =
@@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) {
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
const value = observedNumber(code);
if (value == null) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
const step = (type) => ({
maneuver: { type, location: [10, 20] },
distance: 10,
duration: 1,
});
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
assert.equal(
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
.truncated,
true,
);
steps[steps.length - 1] = step('roundabout');
const result = normalizeOsrmSteps({
legs: [{ steps: [...steps, step('exit roundabout')] }],
});
assert.equal(result.truncated, false);
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
assert.equal(result.steps.at(-1).distanceM, 20);
});
+1 -1
View File
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
}
continue;
}
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
step.instruction = instructionFor(step);
out.push(step);
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
}
}
return { steps: out, truncated: false };
+2 -2
View File
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
// would present a route that simply stops in the middle of a road.
assert.equal(cut.truncated, true);
const exact = normalizeOsrmSteps({
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
});
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
assert.equal(exact.truncated, false, 'a route that fits is not cut');
});
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
test('missing weather observations never assert clear conditions', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
assert.equal(
normalizeRegionalWeather({ current: { temperature_2m: value } }),
null,
);
}
assert.equal(weatherCodeLabel(0), 'CLEAR');
assert.equal(weatherCodeLabel('0'), 'CLEAR');
const record = normalizeRegionalWeather({
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
});
assert.equal(record.temperatureC, 0);
assert.equal(record.weatherCode, null);
assert.equal(record.windKph, null);
});
+6 -1
View File
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
};
}
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
export function normalizeCameraLongitude(longitude) {
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { cameraAtProgress } from './timeline.js';
const camera = (lon) => ({
lat: 10,
lon,
alt: 1000,
heading: 0,
pitch: -40,
roll: 0,
});
test('director seek follows the short dateline arc in both directions', () => {
for (const [a, b] of [
[179, -179],
[-179, 179],
]) {
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
}
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
});
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
for (const [a, b] of [
[180, -180],
[-180, 180],
]) {
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
}
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
});
test('director seek turns heading and roll the short way for any authored angles', () => {
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
for (const value of [mid.heading, mid.roll]) {
const wrapped = ((value % 360) + 360) % 360;
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
}
});
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAssetDirectorySource } from './source.js';
test('cancellation during the terminating read cannot return a scene asset', async () => {
for (const cancel of [true, false]) {
const controller = new AbortController();
let reads = 0,
cancelled = 0,
released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'content-type': 'application/octet-stream' }),
body: {
getReader() {
return {
async read() {
reads++;
if (reads === 1)
return { done: false, value: new Uint8Array([1, 2]) };
if (cancel) controller.abort();
return { done: true };
},
async cancel() {
cancelled++;
},
releaseLock() {
released++;
},
};
},
},
}),
});
const pending = source({ path: 'scene.bin', signal: controller.signal });
if (cancel) await assert.rejects(pending, { name: 'AbortError' });
else assert.deepEqual([...(await pending).bytes], [1, 2]);
assert.equal(cancelled, 1);
assert.equal(released, 1);
}
});
test('cancellation during reader cleanup cannot return a scene asset', async () => {
const controller = new AbortController();
const reason = new DOMException('scene changed', 'AbortError');
let released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
body: {
getReader() {
let reads = 0;
return {
async read() {
reads++;
return reads === 1
? { done: false, value: new Uint8Array([1]) }
: { done: true };
},
async cancel() {
controller.abort(reason);
},
releaseLock() {
released++;
},
};
},
},
}),
});
await assert.rejects(
source({ path: 'scene.bin', signal: controller.signal }),
(error) => error === reason,
);
assert.equal(released, 1);
});
+3
View File
@@ -41,6 +41,7 @@ export function createAssetDirectorySource({
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
@@ -50,6 +51,8 @@ export function createAssetDirectorySource({
await reader.cancel().catch(() => {});
reader.releaseLock();
}
// Cleanup awaits too; a cancel during it must still discard the asset.
signal?.throwIfAborted();
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
+10 -2
View File
@@ -2,6 +2,7 @@ import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
normalizeCameraLongitude,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
// Double modulo keeps the delta in [-180, 180) for any authored angles,
// as sampleCameraMove does; a single % goes negative below -540.
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
lon:
t === 0
? Number(source.lon)
: t === 1
? Number(target.lon)
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCentre,
boxFromRectangle,
dequantizeBox,
validateBox,
} from './model.js';
import { searchHls } from './catalog.js';
test('missing imagery edges do not turn into invented zero coordinates', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
const box = { west: value, south: 0, east: 1, north: 1 };
assert.equal(validateBox(box).ok, false);
assert.equal(validateBox(box).reason, 'invalid');
assert.equal(boxCentre(box), null);
}
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
assert.equal(
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
true,
);
});
test('every edge and every box entry point refuses a missing edge', async () => {
const blanks = [null, undefined, '', ' ', false, [], {}];
for (const edge of ['west', 'south', 'east', 'north']) {
for (const value of blanks) {
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
// Share-link integers and Cesium radians are converted per edge.
assert.equal(dequantizeBox(box), null, `share ${edge}`);
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
}
}
let fetched = false;
await assert.rejects(
searchHls({
box: { west: ' ', south: 1, east: 2, north: 2 },
fetchImpl: async () => {
fetched = true;
},
}),
TypeError,
);
assert.equal(fetched, false);
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
});
+19 -6
View File
@@ -79,6 +79,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
/** A box edge as a number, or null when it is missing, blank or not numeric. */
function edgeNumber(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
@@ -86,8 +94,8 @@ function normalizeBox(box) {
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
].map(edgeNumber);
if ([west, south, east, north].some((edge) => edge == null)) return null;
return {
west,
south: Math.min(south, north),
@@ -215,11 +223,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
const edge = (value) => {
const number = edgeNumber(value);
return number == null ? null : fn(number);
};
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
west: edge(box.west),
south: edge(box.south),
east: edge(box.east),
north: edge(box.north),
});
}
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tilesForBbox } from './tileMath.js';
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
const bbox = [-10, 60, -20, 85];
const result = tilesForBbox(bbox, 3);
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(keys.length, 24);
assert.equal(new Set(keys).size, 24);
assert.equal(result.total, 24);
assert.equal(result.truncated, false);
const overview = tilesForBbox(bbox, 3, { limit: 16 });
assert.equal(overview.total, 24);
assert.equal(overview.tiles.length, 16);
assert.equal(
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
16,
);
assert.equal(overview.truncated, true);
});
test('a limit that fits every distinct tile is not spent on repeats', () => {
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
// 24-slot overview held repeats and still reported itself truncated.
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(new Set(keys).size, 24);
assert.equal(result.truncated, false);
});
+10 -2
View File
@@ -146,8 +146,16 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
// The date-line halves share a column when the gap between east and west is
// narrower than a tile; count that column once so a capped overview does not
// spend its slots on repeats. Each tile's distance is computed once; ties keep
// their row order.
const unique = [
...new Map(
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
).values(),
];
const ranked = unique
.map((tile) => ({ tile, d: distance2(tile) }))
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
+4 -1
View File
@@ -131,7 +131,8 @@ export class MapSourceController {
* @returns {() => void} Unsubscribe.
*/
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
// A destroyed controller never settles again, so it takes no listeners.
if (typeof listener !== 'function' || this._destroyed) return () => {};
this._subscribers.add(listener);
return () => {
this._subscribers.delete(listener);
@@ -142,6 +143,8 @@ export class MapSourceController {
if (this._destroyed || !this._subscribers.size) return;
const state = this.getState();
for (const listener of [...this._subscribers]) {
if (this._destroyed) return;
if (!this._subscribers.has(listener)) continue;
try {
listener(state);
} catch (error) {
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
function fixture() {
return new MapSourceController(
{
scene: {
globe: { show: true },
requestRender() {},
frameState: {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
},
primitives: { add() {}, remove() {} },
},
imageryLayers: { add() {}, remove() {} },
},
{
registry: {
defaultId: 'fixture',
sources: [
{
descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' },
imagery: async () => ({}),
},
],
},
createImageryLayer: (provider) => ({ provider }),
},
);
}
test('an unsubscribed owner is skipped in the same map settlement delivery', async () => {
const controller = fixture();
const calls = [];
let detach;
controller.subscribe(() => {
calls.push('first');
detach();
});
detach = controller.subscribe(() => calls.push('detached'));
controller.subscribe(() => calls.push('last'));
await controller.setStack('fixture');
assert.deepEqual(calls, ['first', 'last']);
controller.destroy();
});
test('destroying the controller during delivery stops remaining listeners', async () => {
const controller = fixture();
const calls = [];
controller.subscribe(() => {
calls.push('destroy');
controller.destroy();
});
controller.subscribe(() => calls.push('detached'));
await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']);
});
test('a listener re-added after destroy during delivery is not called', async () => {
const controller = fixture();
const calls = [];
const later = () => calls.push('later');
controller.subscribe(() => {
calls.push('destroy');
controller.destroy();
controller.subscribe(later);
});
controller.subscribe(later);
await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']);
});
test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => {
const controller = fixture();
const calls = [];
const twice = () => calls.push('twice');
const readded = () => calls.push('readded');
let offReadded;
controller.subscribe(() => {
calls.push('first');
controller.subscribe(() => calls.push('added-late'));
offReadded();
controller.subscribe(readded);
});
controller.subscribe(twice);
controller.subscribe(twice);
offReadded = controller.subscribe(readded);
await controller.setStack('fixture');
assert.deepEqual(calls, ['first', 'twice', 'readded']);
controller.destroy();
});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
test('gates keep macOS Chrome where it is installed', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }),
MACOS_CHROME,
);
});
test('gates fall back to Puppeteer’s browser without macOS Chrome', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: () => false }),
undefined,
);
});
test('an explicit executable path wins', () => {
assert.equal(
chromeExecutablePath({
env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' },
exists: () => true,
}),
'C:\\Chrome\\chrome.exe',
);
});
+5 -4
View File
@@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn
/** Resolve with the promise's value, or 'timed out' after `ms`. */
function within(promise, ms = 1_000) {
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)),
]);
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve('timed out'), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/**
+10 -12
View File
@@ -272,28 +272,26 @@ export function createTerrainHeights({
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
// What this call resolved or found warm, so the assembly below can report a
// point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES.
// Without it a batch big enough to evict its own entries would report
// points as unresolved that it had in fact resolved.
const resolvedThisCall = new Map();
for (const item of work) {
const entry = cache.get(item.key);
const entry = cacheTouch(item.key);
const fallbackCooling =
entry?.source === 'geoid-fallback' &&
Number.isFinite(entry.retryAt) &&
now < entry.retryAt;
if (
(entry && entry.source === 'reearth') ||
fallbackCooling ||
seenKeys.has(item.key)
)
if ((entry && entry.source === 'reearth') || fallbackCooling) {
resolvedThisCall.set(item.key, entry);
continue;
}
if (seenKeys.has(item.key)) continue;
seenKeys.add(item.key);
uncached.push(item);
}
// What this call resolved, so the assembly below can report a point even
// if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a
// batch big enough to evict its own earlier chunks would report points as
// unresolved that it had in fact just resolved.
const resolvedThisCall = new Map();
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
+41
View File
@@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved'
assert.equal(warm.length, 4);
});
test('warm batch consumers retain their terrain cells ahead of unused cells', async () => {
let calls = 0;
const terrain = createTerrainHeights({
maxCacheEntries: 2,
source: {
async getHeights(points) {
calls++;
return points.map((p) => ({ ellipsoid: 100 + p.lon }));
},
},
});
const p = (lon) => ({ lat: 0, lon });
await terrain.resolveEllipsoidalGround([p(1)]);
await terrain.resolveEllipsoidalGround([p(2)]);
await terrain.resolveEllipsoidalGround([p(1)]);
assert.equal(calls, 2);
await terrain.resolveEllipsoidalGround([p(3)]);
assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101);
assert.equal(terrain.cachedEllipsoidalGround(0, 2), null);
assert.equal(calls, 3);
});
test('a batch still reports its warm points after its own fetches evict them', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 2,
});
await terrain.resolveEllipsoidalGround([pointAt(0)]);
const results = await terrain.resolveEllipsoidalGround([
pointAt(0),
pointAt(1),
pointAt(2),
]);
results.forEach((result, i) => {
assert.equal(result.source, 'reearth', `point ${i} reported resolved`);
assert.equal(result.ellipsoid, heightAt(lonAt(i)));
});
});
test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => {
for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) {
const terrain = createTerrainHeights({
+3 -1
View File
@@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
/** Format an epoch millisecond time as ISO 8601, or null. */
export function isoTime(ms) {
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
if (!Number.isFinite(ms)) return null;
const date = new Date(ms);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
/** "1 earthquake", "3 earthquakes". */
+14 -4
View File
@@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') {
fail('must be a string');
break;
}
if (schema.minLength != null && value.length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && value.length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
if (schema.minLength != null || schema.maxLength != null) {
// Code points, as JSON Schema counts. Counting stops once both bounds
// are decided, so a huge argument costs no more than its limit.
const limit = Math.max(
schema.minLength ?? 0,
schema.maxLength != null ? schema.maxLength + 1 : 0,
);
let length = 0;
for (const _ of value) if (++length >= limit) break;
if (schema.minLength != null && length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
}
if (
schema.pattern !== undefined &&
!new RegExp(schema.pattern, 'u').test(value)
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateValue } from './schema.js';
test('tool string schemas count Unicode characters rather than UTF-16 units', () => {
const value = '𠮷野家😀';
assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []);
assert.equal(
validateValue({ type: 'string', minLength: 5 }, value).length,
1,
);
assert.equal(
validateValue({ type: 'string', maxLength: 3 }, value).length,
1,
);
assert.deepEqual(
validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'),
[],
);
assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1);
});
test('tool string bounds hold at their edges and on huge arguments', () => {
const string = (bounds) => ({ type: 'string', ...bounds });
assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []);
assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1);
assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []);
assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []);
// Counting all of it takes roughly 0.7 s; stopping at the limit takes none.
const huge = 'x'.repeat(1e8);
const started = performance.now();
assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1);
assert.ok(performance.now() - started < 200, 'stops counting past the limit');
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isoTime } from './results.js';
import { getBikeShare } from './queries/mobility.js';
test('out-of-range finite timestamps are unknown rather than throwing', async () => {
for (const value of [1e30, -1e30, NaN, Infinity, null])
assert.equal(isoTime(value), null);
assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z');
assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z');
assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z');
for (const value of [8640000000000001, -8640000000000001, -Infinity])
assert.equal(isoTime(value), null);
assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z');
const station = {
station_id: 's1',
name: 'Fixture',
lat: 0,
lon: 0,
capacity: 4,
};
const result = await getBikeShare.run(
{ area: { bbox: [-1, -1, 1, 1] } },
{
services: {
bikeshare: {
systems: [
{
id: 'fixture',
city: 'Fixture',
centerLat: 0,
centerLon: 0,
loadRadiusKm: 30,
stationInformationUrl: 'information',
stationStatusUrl: 'status',
},
],
async getStations(url) {
return {
data: {
stations:
url === 'information'
? [station]
: [
{
station_id: 's1',
last_reported: 1e30,
num_bikes_available: 2,
},
],
},
};
},
},
},
},
);
assert.equal(result.data.returned, 1);
assert.equal(result.data.rows[0].last_reported, null);
});
+3 -2
View File
@@ -4818,8 +4818,9 @@ function compactAnalystItem(r) {
for (const [key, spec] of Object.entries(fields)) {
if (!present(r[key]) || key in compact) continue;
if (spec.type === 'time') {
const ms = Number(r[key]);
if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString();
// A finite value can still be past Date's range, where toISOString throws.
const time = new Date(Number(r[key]));
if (Number.isFinite(time.getTime())) compact.time = time.toISOString();
} else compact[key] = r[key];
}
if (present(r.ageHours)) compact.ageHours = r.ageHours;
+20
View File
@@ -3459,6 +3459,26 @@ test('analyst_query refusals carry their code and the allowed values', async ()
assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/);
});
test('analyst_query keeps its answer when one record has an out-of-range time', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const now = Date.now();
const earthquakes = {
getStats: () => ({ count: 2, lastUpdate: now }),
getAnalystRecords: () => [
{ id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now },
{ id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 },
],
};
const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] });
const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(result.ok, true);
const q = result.items.find((item) => item.id === 'q');
assert.match(q.time, /^\d{4}-\d\d-\d\dT/);
const r = result.items.find((item) => item.id === 'r');
assert.ok(r, 'the record is still listed');
assert.equal(r.time, undefined, 'an unrepresentable time is omitted');
});
test('spoken layer names from the manifest reach the newer layers', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const stub = () => ({ getStats: () => ({ count: 0 }) });
+7 -1
View File
@@ -13,7 +13,13 @@ function range01(value, min, max) {
* Missing weather fails clear instead of inventing conditions.
*/
export function deriveWeatherEffectProfile(weather) {
if (!weather || !Number.isFinite(Number(weather.weatherCode))) {
const reported = weather?.weatherCode;
// Number(null) and Number('') are 0 (clear), so a missing code is checked first.
if (
reported == null ||
reported === '' ||
!Number.isFinite(Number(reported))
) {
return {
available: false,
cloud: 0,
+9
View File
@@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => {
assert.equal(profile.storm, 0);
});
test('a missing weather code is unavailable, not clear', () => {
for (const weatherCode of [null, undefined, '']) {
const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 });
assert.equal(profile.available, false, `weatherCode ${weatherCode}`);
assert.equal(profile.cloud, 0);
}
assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true);
});
test('clear weather follows observed cloud cover without precipitation', () => {
const profile = deriveWeatherEffectProfile({
weatherCode: 1,