From de4c8343ddd3ce3a164e0d44e63cf3c7e805f477 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:33:46 -0500 Subject: [PATCH 01/46] test: make Recent Imagery live gate panel-relative --- scripts/qa-recent-imagery.mjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 1460ed3b5..f9601432f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -308,15 +308,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); @@ -1042,13 +1048,13 @@ try { ) <= target * 0.01, fitHeight, - 2_000, + 4_000, ); 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 lands at ${Math.round(fitHeight / 1000)} km`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1258,7 +1264,7 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(8).map((s) => s[key])); + const tail = (key) => new Set(samples.slice(-8).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(',')}`, From 8d1f69a8731b6cab3fb95f1bf6d5cd05d89f7e85 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:28 -0500 Subject: [PATCH 02/46] ci: prove Recent Imagery gate repair --- .../recent-imagery-gate-repair-proof.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml new file mode 100644 index 000000000..b613d9561 --- /dev/null +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -0,0 +1,37 @@ +name: Recent Imagery gate repair proof + +on: + push: + branches: + - fix/recent-imagery-qa-relative-layout-20261003 + +permissions: + contents: read + +jobs: + browser-proof: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - name: Start GEV + run: | + npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & + for i in {1..90}; do + if curl -fsS http://127.0.0.1:4173/ >/dev/null; then + exit 0 + fi + sleep 1 + done + cat /tmp/gev-dev.log + exit 1 + - name: Recent Imagery live gate + run: node scripts/qa-recent-imagery.mjs + - name: Dev log on failure + if: failure() + run: cat /tmp/gev-dev.log From 123bf55f39a7dc12cad6215b362da0acc91c6c23 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:30 -0700 Subject: [PATCH 03/46] fix(qa): repoint stale mutation anchors after the September refactors The three mutation gates matched source text that has since moved or been reflowed, so 37 of their 108 reverts reported STALE and could no longer prove anything: - qa-floorhold-mutations (2): the neighbour-floor code moved from src/data/groundFloor.js into src/services/groundFloor.js (ac977bd). - qa-flyroute-mutations (8): src/cameraVerbs.js was reflowed by the formatter (2aca7ae); the anchors now match the formatted lines. - qa-firstrun-mutations (27): the launcher markup, styles and startup moved to src/ui/templates/welcome.html, src/ui/styles/first-run.css and src/app/startupChrome.js; the rail default to panelPositionControls.js and the radio ESC handling to radioBindings.js (a67bc60 and follow-ups). The tool-schema pin now edits a tool description, since the context-mode enum it used to edit no longer lives in tools.js. Every revert still reintroduces the same defect. Results: floor-hold 22/22, fly_route 19/19, first-run 67/67 caught, with all source files restored. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-firstrun-mutations.mjs | 68 ++++++++++++++++-------------- scripts/qa-floorhold-mutations.mjs | 10 ++--- scripts/qa-flyroute-mutations.mjs | 20 ++++----- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index b8b7c9f5e..3ef331abd 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -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 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', diff --git a/scripts/qa-floorhold-mutations.mjs b/scripts/qa-floorhold-mutations.mjs index 0e2ac9d62..b193ff169 100644 --- a/scripts/qa-floorhold-mutations.mjs +++ b/scripts/qa-floorhold-mutations.mjs @@ -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;', }, ], }, diff --git a/scripts/qa-flyroute-mutations.mjs b/scripts/qa-flyroute-mutations.mjs index dbece6dba..d7d3d3325 100644 --- a/scripts/qa-flyroute-mutations.mjs +++ b/scripts/qa-flyroute-mutations.mjs @@ -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', From 9501dc121bce1552c4ce50dfa4d448064165f97e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:01 -0500 Subject: [PATCH 04/46] test: preserve strict default timing with opt-in slow CI gate --- .../recent-imagery-gate-repair-proof.yml | 2 ++ scripts/qa-recent-imagery.mjs | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml index b613d9561..d93925e8c 100644 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ b/.github/workflows/recent-imagery-gate-repair-proof.yml @@ -31,6 +31,8 @@ jobs: cat /tmp/gev-dev.log exit 1 - name: Recent Imagery live gate + env: + QA_RECENT_IMAGERY_SLOW_RENDER: '1' run: node scripts/qa-recent-imagery.mjs - name: Dev log on failure if: failure() diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f9601432f..f81209b9f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -12,8 +12,10 @@ * 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'; @@ -22,6 +24,9 @@ import puppeteer from 'puppeteer'; const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173'; const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/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'; @@ -1048,13 +1053,13 @@ try { ) <= target * 0.01, fitHeight, - 4_000, + ZOOM_TIMEOUT_MS, ); const zoomedHeight = await page.evaluate( () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN lands at ${Math.round(fitHeight / 1000)} km`, + `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1264,7 +1269,7 @@ 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(',')}`, From 9d59e555c2b1bd2053296561f9dc01ba64ed551b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:47 -0500 Subject: [PATCH 05/46] style: format slow-CI timing assertions --- scripts/qa-recent-imagery.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index f81209b9f..9c1d6700e 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -1059,7 +1059,7 @@ try { () => window.__godsEyeView.viewer.camera.positionCartographic.height, ); check( - `ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within ${ZOOM_TIMEOUT_MS / 1_000} s`, + `ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`, zoomed, `height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`, ); @@ -1269,7 +1269,8 @@ try { ); await wait(100); } - const tail = (key) => new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).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(',')}`, From 2357a1140907720f6bfc7037a992196e83057dfb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:36:02 -0500 Subject: [PATCH 06/46] ci: remove fork-only Recent Imagery proof workflow per review --- .../recent-imagery-gate-repair-proof.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/recent-imagery-gate-repair-proof.yml diff --git a/.github/workflows/recent-imagery-gate-repair-proof.yml b/.github/workflows/recent-imagery-gate-repair-proof.yml deleted file mode 100644 index d93925e8c..000000000 --- a/.github/workflows/recent-imagery-gate-repair-proof.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Recent Imagery gate repair proof - -on: - push: - branches: - - fix/recent-imagery-qa-relative-layout-20261003 - -permissions: - contents: read - -jobs: - browser-proof: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - name: Start GEV - run: | - npm run dev -- --host 127.0.0.1 --port 4173 > /tmp/gev-dev.log 2>&1 & - for i in {1..90}; do - if curl -fsS http://127.0.0.1:4173/ >/dev/null; then - exit 0 - fi - sleep 1 - done - cat /tmp/gev-dev.log - exit 1 - - name: Recent Imagery live gate - env: - QA_RECENT_IMAGERY_SLOW_RENDER: '1' - run: node scripts/qa-recent-imagery.mjs - - name: Dev log on failure - if: failure() - run: cat /tmp/gev-dev.log From 632005d221f2a5c60045769208c4b442d69cf3e5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:27 -0400 Subject: [PATCH 07/46] fix(director): seek camera longitude across the shortest dateline arc --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/director/director-dateline-seek.test.mjs | 26 ++++++++++++++++++++ src/director/timeline.js | 8 +++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/director/director-dateline-seek.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..2075effa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Director timeline seeks cross the dateline along the short longitude arc. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..cb0b43c54 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): seek camera longitude across the shortest dateline arc + +Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs new file mode 100644 index 000000000..f950814f3 --- /dev/null +++ b/src/director/director-dateline-seek.test.mjs @@ -0,0 +1,26 @@ +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); +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index 7fd4cbbae..1da08d498 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -45,7 +45,13 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { }; return { lat: lerp(source.lat, target.lat), - lon: lerp(source.lon, target.lon), + lon: + t === 0 + ? Number(source.lon) + : t === 1 + ? Number(target.lon) + : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - + 180, alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 3b08bd74872a4a7c2dac103139a05ce660fd71f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:42 -0400 Subject: [PATCH 08/46] fix: address review feedback for PR #1023 --- docs/CURRENT-STATE.md | 6 +----- src/director/camera.js | 7 ++++++- src/director/timeline.js | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb0b43c54..37a9e6c0a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,6 +399,7 @@ 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. See [camera directions](DIRECTOR-CAMERA.md). Realtime voice composes separate connection, response/tool, Radio handoff, input @@ -5310,8 +5311,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): seek camera longitude across the shortest dateline arc - -Interpolate longitude with the existing shortest-angle easing, normalize intermediate longitudes, and preserve exact authored endpoints at progress zero and one. diff --git a/src/director/camera.js b/src/director/camera.js index 059c7a873..e009c2cba 100644 --- a/src/director/camera.js +++ b/src/director/camera.js @@ -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), diff --git a/src/director/timeline.js b/src/director/timeline.js index 1da08d498..fbaca4301 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -2,6 +2,7 @@ import { resolveCameraPose, resolveCameraMove, sampleCameraMove, + normalizeCameraLongitude, } from './camera.js'; const DEFAULT_SHOT_DURATION_SEC = 4; @@ -50,8 +51,7 @@ export function cameraAtProgress(fromCamera, toCamera, progress) { ? Number(source.lon) : t === 1 ? Number(target.lon) - : ((((lerpAngle(source.lon, target.lon) + 180) % 360) + 360) % 360) - - 180, + : normalizeCameraLongitude(lerpAngle(source.lon, target.lon)), alt: lerp(source.alt, target.alt), heading: lerpAngle(source.heading, target.heading), pitch: lerp(source.pitch, target.pitch), From 2c5fb039ce1da3b0ce7ba9e177499b16c14c4656 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:33:43 -0400 Subject: [PATCH 09/46] fix(tools): enforce string schema lengths by Unicode code point --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/tools/schema.js | 5 +++-- src/tools/tool-schema-codepoints.test.mjs | 20 ++++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/tools/tool-schema-codepoints.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..f7607fd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..7e1970768 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): enforce string schema lengths by Unicode code point + +Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index 7c40c3eb5..cdb63cfc1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,9 +106,10 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - if (schema.minLength != null && value.length < schema.minLength) + const length = [...value].length; + if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); - if (schema.maxLength != null && value.length > schema.maxLength) + if (schema.maxLength != null && length > schema.maxLength) fail(`must be at most ${schema.maxLength} characters`); if ( schema.pattern !== undefined && diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs new file mode 100644 index 000000000..c379b77e9 --- /dev/null +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -0,0 +1,20 @@ +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); +}); From 615ba9a00826a50c2907184b9193c6e6d51bdc0b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:53 -0400 Subject: [PATCH 10/46] fix: address review feedback for PR #1026 --- docs/CURRENT-STATE.md | 7 ++----- src/tools/schema.js | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e1970768..e66800fd5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -73,6 +73,8 @@ 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. + `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, @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): enforce string schema lengths by Unicode code point - -Count code points for minLength and maxLength, preserving ASCII behavior and regular-expression validation. diff --git a/src/tools/schema.js b/src/tools/schema.js index cdb63cfc1..b6e8e2fd0 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,7 +106,8 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - const length = [...value].length; + let length = 0; + for (const character of value) length += 1; if (schema.minLength != null && length < schema.minLength) fail(`must be at least ${schema.minLength} characters`); if (schema.maxLength != null && length > schema.maxLength) From c86c1a4ccedb0468bc622c00ee151c1224bab71a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:35:40 -0400 Subject: [PATCH 11/46] fix(weather): keep absent observations unknown instead of reporting clear skies --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 ++++ src/data/regionalModel.js | 23 +++++++++++++++++-- .../weather-missing-observations.test.mjs | 20 ++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/data/weather-missing-observations.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..d3a8f9027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Weather records keep missing conditions unknown and refuse summaries without a measured temperature. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..c066ab182 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(weather): keep absent observations unknown instead of reporting clear skies + +Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..90eeac1fd 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -90,9 +90,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { /** 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; + if ( + !current || + current.temperature_2m == null || + typeof current.temperature_2m === 'boolean' || + (typeof current.temperature_2m === 'string' && + !current.temperature_2m.trim()) || + !Number.isFinite(Number(current.temperature_2m)) + ) + return null; const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return null; return Number.isFinite(Number(value)) ? Number(value) : null; }; // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, @@ -119,6 +132,12 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { + if ( + code == null || + typeof code === 'boolean' || + (typeof code === 'string' && !code.trim()) + ) + return 'CONDITIONS UNKNOWN'; const value = Number(code); if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; if (value === 0) return 'CLEAR'; diff --git a/src/data/weather-missing-observations.test.mjs b/src/data/weather-missing-observations.test.mjs new file mode 100644 index 000000000..4e89dcedd --- /dev/null +++ b/src/data/weather-missing-observations.test.mjs @@ -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); +}); From 7126555acb42d8a2c9bb7e60a14fc569a628bccf Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:54 -0400 Subject: [PATCH 12/46] fix: address review feedback for PR #1027 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c066ab182..076603256 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4625,6 +4625,8 @@ 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. +Missing local weather observations remain unknown rather than being converted to zero or clear skies; 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. @@ -5310,8 +5312,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(weather): keep absent observations unknown instead of reporting clear skies - -Refuse absent or boolean primary temperatures and keep absent/boolean weather codes explicitly CONDITIONS UNKNOWN while retaining valid numeric strings and real zero observations. From a140c52f847b4b3cf7f9bfe1b6b4597afd25747f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:36:07 -0400 Subject: [PATCH 13/46] fix(directions): report truncation only when a maneuver was actually omitted --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/data/route-cap-truthfulness.test.mjs | 24 ++++++++++++++++++++++++ src/data/routeSteps.js | 2 +- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/data/route-cap-truthfulness.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..e1518ce6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d6222d72e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(directions): report truncation only when a maneuver was actually omitted + +Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. diff --git a/src/data/route-cap-truthfulness.test.mjs b/src/data/route-cap-truthfulness.test.mjs new file mode 100644 index 000000000..7d5d80153 --- /dev/null +++ b/src/data/route-cap-truthfulness.test.mjs @@ -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); +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index 5ee11602a..e3144bdf7 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -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 }; From 69815a92bec2f3ceaf41aecf919b6f78edc8818a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 14/46] docs: address review scope and feature placement for #1030 --- docs/CURRENT-STATE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d6222d72e..607449cf8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3317,6 +3317,10 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. +Directions retains up to 200 turn instructions. Its truncation notice appears +only when another independent instruction is omitted; a complete route with +exactly 200 turns does not show the notice. + Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the profile; SET A and SET B arm the next globe click (Escape or a second press @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(directions): report truncation only when a maneuver was actually omitted - -Keep processing foldable exits after the cap and report truncation only when the next admissible independent instruction would exceed it. The returned list remains bounded to 200 steps. From ba58a5ec50286ce4b06ac483b2eff9ddcc12aa91 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:05 -0400 Subject: [PATCH 15/46] fix(street-level): deduplicate overlapping dateline tile halves --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ .../street-level-unique-tiles.test.mjs | 15 +++++++++++++++ src/layers/streetLevel/tileMath.js | 7 ++++++- 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/layers/streetLevel/street-level-unique-tiles.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..0814f7e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..d0014227f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(street-level): deduplicate overlapping dateline tile halves + +Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs new file mode 100644 index 000000000..f96f0b892 --- /dev/null +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { tilesForBbox } from './tileMath.js'; +test('coarse dateline coverage counts each tile exactly once', () => { + for (const z of [0, 1, 2]) { + const result = tilesForBbox([170, -1, -170, 1], z); + const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); + assert.equal(new Set(keys).size, keys.length); + assert.equal(result.total, keys.length); + const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); + assert.equal(limited.total, keys.length); + assert.equal(limited.truncated, keys.length > 1); + assert.equal(limited.tiles.length, 1); + } +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index fe3c3ec18..ca7b09cd5 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -147,7 +147,12 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { 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 + 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); From f67888be00327c45bb2f0a651979ea9eb77489f4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:01 -0400 Subject: [PATCH 16/46] docs: address review scope and feature placement for #1032 --- CHANGELOG.md | 3 ++ docs/CURRENT-STATE.md | 9 +++--- .../street-level-unique-tiles.test.mjs | 29 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814f7e7c..1d76a1963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. +- Street Level counts unique tiles before ranking so overlapping dateline halves + in polar views do not waste slots in the 16-tile overview budget. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d0014227f..c2291829e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3122,6 +3122,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, @@ -5310,8 +5314,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(street-level): deduplicate overlapping dateline tile halves - -Deduplicate tile addresses before ranking, truncating and reporting total coverage. Dateline ordering and the existing focus policy remain in control. diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index f96f0b892..224de523b 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -1,15 +1,22 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { tilesForBbox } from './tileMath.js'; -test('coarse dateline coverage counts each tile exactly once', () => { - for (const z of [0, 1, 2]) { - const result = tilesForBbox([170, -1, -170, 1], z); - const keys = result.tiles.map((t) => `${t.x}/${t.y}/${t.z}`); - assert.equal(new Set(keys).size, keys.length); - assert.equal(result.total, keys.length); - const limited = tilesForBbox([170, -1, -170, 1], z, { limit: 1 }); - assert.equal(limited.total, keys.length); - assert.equal(limited.truncated, keys.length > 1); - assert.equal(limited.tiles.length, 1); - } + +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); }); From 4adcb3f7274951bd5c763cc59e190ef559c169de Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:17 -0400 Subject: [PATCH 17/46] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d76a1963..69748384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Street Level requests count each tile once when dateline bounds overlap at coarse zoom levels. - - Street Level counts unique tiles before ranking so overlapping dateline halves in polar views do not waste slots in the 16-tile overview budget. From 8daaa065d2b410380e18a5702112d7cad7e2e900 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 14:16:30 -0700 Subject: [PATCH 18/46] qa: log the Recent Imagery gate's stability mode and document its flag --- docs/CURRENT-STATE.md | 2 +- scripts/qa-recent-imagery.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ebf7495f5..91ba81591 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4451,7 +4451,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) diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 9c1d6700e..5d42279dc 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -383,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) From 4cacd4cc0021858c009d38a977095fac73a49136 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:53 -0700 Subject: [PATCH 19/46] fix(qa): exit QA gates gracefully on Windows (#1064) process.exit(2) after a successful fetch trips a libuv assertion on Windows and turns the exit code into 127. The four gates set process.exitCode and return instead, and discard the unread reachability response so an early exit does not wait for the server to finish the body. Co-authored-by: Sameh Khamis --- scripts/qa-firms.mjs | 8 ++++++-- scripts/qa-traffic-jamviz-ab.mjs | 8 ++++++-- scripts/qa-traffic-preset-ab.mjs | 8 ++++++-- scripts/qa-traffic.mjs | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/scripts/qa-firms.mjs b/scripts/qa-firms.mjs index d32ede533..27c8663bf 100644 --- a/scripts/qa-firms.mjs +++ b/scripts/qa-firms.mjs @@ -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 }); diff --git a/scripts/qa-traffic-jamviz-ab.mjs b/scripts/qa-traffic-jamviz-ab.mjs index 73d28ca60..b236ab0b1 100644 --- a/scripts/qa-traffic-jamviz-ab.mjs +++ b/scripts/qa-traffic-jamviz-ab.mjs @@ -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 }); diff --git a/scripts/qa-traffic-preset-ab.mjs b/scripts/qa-traffic-preset-ab.mjs index 44319af0d..d1b4950fd 100644 --- a/scripts/qa-traffic-preset-ab.mjs +++ b/scripts/qa-traffic-preset-ab.mjs @@ -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 }); diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 2a04d5595..424f3486e 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -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 }); From 504d56ba2b3d73dcad45c14cd0715764c8cb2955 Mon Sep 17 00:00:00 2001 From: Ian Blenke Date: Wed, 14 Oct 2026 17:58:53 -0400 Subject: [PATCH 20/46] test: fix flaky timing and timeout guards (#969) Clears the timeout guards once each race settles, measures the 15,000-vehicle transit ingestion with main-thread CPU time instead of wall time, and times the region-timeout case from the boundary lookup with a 2 s ceiling, below the 3 s default budget, so an ignored budget still fails. Co-authored-by: Sameh Khamis --- server/providers/transitHistory.test.mjs | 16 +++++++++------- src/annotations/regionRing.test.mjs | 7 +++++-- src/data/manager.test.mjs | 14 ++++++++++---- src/sdr/controller.test.mjs | 9 +++++---- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index 6ea9209f1..cb06c182a 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -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) => { diff --git a/src/annotations/regionRing.test.mjs b/src/annotations/regionRing.test.mjs index 09814247b..cd8ad6447 100644 --- a/src/annotations/regionRing.test.mjs +++ b/src/annotations/regionRing.test.mjs @@ -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. diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index fce64ae8f..23641c962 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -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(); diff --git a/src/sdr/controller.test.mjs b/src/sdr/controller.test.mjs index 95b1eb5f2..f8078dbe5 100644 --- a/src/sdr/controller.test.mjs +++ b/src/sdr/controller.test.mjs @@ -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)); } /** From cbefd529f9ca08d55e874240142d495cdf1d342f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:05 -0700 Subject: [PATCH 21/46] fix(director): turn heading and roll the short way for any authored angle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single % made the seek delta negative when the target angle was more than 540° below the start, so 350° to -350° turned 340° through 180°. Uses the same double modulo as playback, and adds tests for exact ±180° endpoints and a near-antipodal seek. --- docs/CURRENT-STATE.md | 4 ++- src/director/director-dateline-seek.test.mjs | 27 ++++++++++++++++++++ src/director/timeline.js | 4 ++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa48b7bfe..3a7f7b94f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -399,7 +399,9 @@ 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. +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 diff --git a/src/director/director-dateline-seek.test.mjs b/src/director/director-dateline-seek.test.mjs index f950814f3..b0dd2b24b 100644 --- a/src/director/director-dateline-seek.test.mjs +++ b/src/director/director-dateline-seek.test.mjs @@ -24,3 +24,30 @@ test('director seek follows the short dateline arc in both directions', () => { } 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}`); + } +}); diff --git a/src/director/timeline.js b/src/director/timeline.js index fbaca4301..f7cffcece 100644 --- a/src/director/timeline.js +++ b/src/director/timeline.js @@ -41,7 +41,9 @@ 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 { From c12938cd05b843ca00e8bf0ac4be5d5c5770acaa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:05:40 -0700 Subject: [PATCH 22/46] perf(tools): stop counting string code points once the bounds are decided Strings without length bounds are not counted, and counting stops at the larger bound, so a huge tool argument costs no more than its limit. --- src/tools/schema.js | 20 ++++++++++++++------ src/tools/tool-schema-codepoints.test.mjs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/tools/schema.js b/src/tools/schema.js index b6e8e2fd0..f857825a1 100644 --- a/src/tools/schema.js +++ b/src/tools/schema.js @@ -106,12 +106,20 @@ export function validateValue(schema, value, path = 'arguments') { fail('must be a string'); break; } - let length = 0; - for (const character of value) length += 1; - 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.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) diff --git a/src/tools/tool-schema-codepoints.test.mjs b/src/tools/tool-schema-codepoints.test.mjs index c379b77e9..27af78fa3 100644 --- a/src/tools/tool-schema-codepoints.test.mjs +++ b/src/tools/tool-schema-codepoints.test.mjs @@ -18,3 +18,16 @@ test('tool string schemas count Unicode characters rather than UTF-16 units', () ); 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'); +}); From 5be2185dc79353cf76969a13c4372b20b15428d2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 15:06:52 -0700 Subject: [PATCH 23/46] fix(weather): treat a missing code as unavailable in cloud effects too Number(null) is 0, so the cockpit cloud profile treated a missing weather code as clear but available. The briefing's three checks now share one helper, and the current-state note is its own bullet instead of folding into the route one. --- docs/CURRENT-STATE.md | 3 +-- src/data/regionalModel.js | 41 ++++++++++++--------------------- src/weatherEffectsMath.js | 8 ++++++- src/weatherEffectsMath.test.mjs | 9 ++++++++ 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f62c77bab..0ec9548ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4632,8 +4632,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. -Missing local weather observations remain unknown rather than being converted to zero or clear skies; a missing temperature drops the whole local weather record, including its wind and condition. - +- **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. diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 90eeac1fd..3a6d79f14 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -87,27 +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 || - current.temperature_2m == null || - typeof current.temperature_2m === 'boolean' || - (typeof current.temperature_2m === 'string' && - !current.temperature_2m.trim()) || - !Number.isFinite(Number(current.temperature_2m)) - ) - return null; - const numberOrNull = (value) => { - if ( - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()) - ) - 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 = @@ -132,14 +127,8 @@ export function normalizeRegionalWeather(payload) { /** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ export function weatherCodeLabel(code) { - if ( - code == null || - typeof code === 'boolean' || - (typeof code === 'string' && !code.trim()) - ) - return 'CONDITIONS UNKNOWN'; - 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'; diff --git a/src/weatherEffectsMath.js b/src/weatherEffectsMath.js index 0c1276bab..ec06741aa 100644 --- a/src/weatherEffectsMath.js +++ b/src/weatherEffectsMath.js @@ -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, diff --git a/src/weatherEffectsMath.test.mjs b/src/weatherEffectsMath.test.mjs index 6ca4387bd..bed3d1cd4 100644 --- a/src/weatherEffectsMath.test.mjs +++ b/src/weatherEffectsMath.test.mjs @@ -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, From e50bc45674c4e7f940dce07f3c67b7c1ed83fb67 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:36:35 -0700 Subject: [PATCH 24/46] fix(qa): run the macOS-only gates on Windows and Linux (#1061) Seven QA scripts hard-coded macOS Google Chrome. They now share chromeExecutablePath(): PUPPETEER_EXECUTABLE_PATH when set, then macOS Chrome when installed, otherwise Puppeteer's managed browser. The cables screenshot path is built with fileURLToPath so it works on Windows. qa-overlay-baseline keeps its fixed Chrome because its baselines record the executable. Co-authored-by: Sameh Khamis --- scripts/format-scope.json | 2 ++ scripts/precompute-cctv-heights.mjs | 4 ++-- scripts/qa-cables-shot.mjs | 5 ++++- scripts/qa-chrome.mjs | 20 ++++++++++++++++++++ scripts/qa-directions.mjs | 4 ++-- scripts/qa-draw-tool.mjs | 4 ++-- scripts/qa-recent-imagery.mjs | 4 ++-- scripts/qa-transit-heading.mjs | 4 ++-- scripts/qa-transit-recovery.mjs | 4 ++-- scripts/qa-transit.mjs | 4 ++-- src/qaChrome.test.mjs | 27 +++++++++++++++++++++++++++ 11 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 scripts/qa-chrome.mjs create mode 100644 src/qaChrome.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 1a880b284..296880561 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/precompute-cctv-heights.mjs b/scripts/precompute-cctv-heights.mjs index 24813b5e7..648768341 100644 --- a/scripts/precompute-cctv-heights.mjs +++ b/scripts/precompute-cctv-heights.mjs @@ -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', diff --git a/scripts/qa-cables-shot.mjs b/scripts/qa-cables-shot.mjs index 12af9beb8..3e3008e03 100644 --- a/scripts/qa-cables-shot.mjs +++ b/scripts/qa-cables-shot.mjs @@ -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}`); } diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs new file mode 100644 index 000000000..05c4dbff0 --- /dev/null +++ b/scripts/qa-chrome.mjs @@ -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; +} diff --git a/scripts/qa-directions.mjs b/scripts/qa-directions.mjs index 9501690f9..ca271026f 100644 --- a/scripts/qa-directions.mjs +++ b/scripts/qa-directions.mjs @@ -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', diff --git a/scripts/qa-draw-tool.mjs b/scripts/qa-draw-tool.mjs index a5a16af1d..adb29f3af 100644 --- a/scripts/qa-draw-tool.mjs +++ b/scripts/qa-draw-tool.mjs @@ -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: [ diff --git a/scripts/qa-recent-imagery.mjs b/scripts/qa-recent-imagery.mjs index 5d42279dc..89d60d96f 100644 --- a/scripts/qa-recent-imagery.mjs +++ b/scripts/qa-recent-imagery.mjs @@ -20,6 +20,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/recent-imagery'; @@ -54,8 +55,7 @@ const browser = await puppeteer.launch({ headless: !HEADFUL, ...(HEADFUL ? { - executablePath: - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + executablePath: chromeExecutablePath(), } : {}), args: [ diff --git a/scripts/qa-transit-heading.mjs b/scripts/qa-transit-heading.mjs index a16bab9bc..c229be96c 100644 --- a/scripts/qa-transit-heading.mjs +++ b/scripts/qa-transit-heading.mjs @@ -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 { diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index e5fadda49..5f6404b41 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -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', diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index aac1d8f60..7d83d77a4 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -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', diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs new file mode 100644 index 000000000..4414f3c93 --- /dev/null +++ b/src/qaChrome.test.mjs @@ -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', + ); +}); From 51d335b1db56a767ceab9aed3238889aaccebcdd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:10 -0700 Subject: [PATCH 25/46] test: check the route cap at exactly 200 turns The existing "route that fits" case used 199 steps, which is how the off-by-one went unnoticed. Folds the docs note into the existing cap sentence and makes the changelog entry specific. --- CHANGELOG.md | 3 ++- docs/CURRENT-STATE.md | 11 ++++------- src/data/routeSteps.test.mjs | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31fa3b502..a770fbd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Direction instruction limits report complete routes honestly and retain a final roundabout exit within the limit. +- 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c98b2f667..1f794a5b5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3327,10 +3327,6 @@ from fixtures (`providers/mapillary/coverageFixture.mjs`, photo flow. CI runs the fixture gate and `qa:panel-resize` against a production build served by `vite preview` with a dummy token. -Directions retains up to 200 turn instructions. Its truncation notice appears -only when another independent instruction is omitted; a complete route with -exactly 200 turns does not show the notice. - Directions is a keyless front end to the routing the voice agent already uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the profile; SET A and SET B arm the next globe click (Escape or a second press @@ -3403,9 +3399,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. diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index a3c8cf227..6bf74dd08 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -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'); }); From 84714030c86ca42a770aa04e19ae52fabf457dc8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:35:47 -0700 Subject: [PATCH 26/46] test: show a capped overview no longer holds repeat tiles Adds a case that fails before de-duplication (a 24-slot overview held repeats and reported itself truncated) and says why the date-line halves overlap. --- .../streetLevel/street-level-unique-tiles.test.mjs | 9 +++++++++ src/layers/streetLevel/tileMath.js | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/layers/streetLevel/street-level-unique-tiles.test.mjs b/src/layers/streetLevel/street-level-unique-tiles.test.mjs index 224de523b..52e7b6aea 100644 --- a/src/layers/streetLevel/street-level-unique-tiles.test.mjs +++ b/src/layers/streetLevel/street-level-unique-tiles.test.mjs @@ -20,3 +20,12 @@ test('reachable polar dateline coverage ranks 24 distinct tiles before overview ); 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); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index ca7b09cd5..350188200 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -146,7 +146,10 @@ 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. + // 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]), From c5ac24b6005d4c070378a99544fd78d6bc21ef09 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:40:12 -0400 Subject: [PATCH 27/46] fix(recent-imagery): reject absent box edges instead of inventing zero degrees --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++++ .../imagery-box-missing-edge.test.mjs | 16 ++++++++++++++++ src/layers/recentImagery/model.js | 17 +++++++++++------ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/layers/recentImagery/imagery-box-missing-edge.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..95a92c46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. +- Recent Imagery refuses missing bounding-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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..1ad836aba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(recent-imagery): reject absent box edges instead of inventing zero degrees + +Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs new file mode 100644 index 000000000..3e5a8e8a5 --- /dev/null +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateBox, boxCentre } from './model.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, + ); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 23f503e9e..63c5ec16c 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -77,12 +77,17 @@ const pad2 = (value) => String(value).padStart(2, '0'); */ function normalizeBox(box) { if (!box || typeof box !== 'object') return null; - const [west, south, east, north] = [ - box.west, - box.south, - box.east, - box.north, - ].map(Number); + const edges = [box.west, box.south, box.east, box.north]; + if ( + edges.some( + (value) => + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()), + ) + ) + return null; + const [west, south, east, north] = edges.map(Number); if (![west, south, east, north].every(Number.isFinite)) return null; return { west, From 9c9c80a7ed489c5ba1bb4a7159e87103e7ae25d2 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:03 -0400 Subject: [PATCH 28/46] docs: address review scope and feature placement for #1036 --- CHANGELOG.md | 3 +++ docs/CURRENT-STATE.md | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a92c46f..dddcdeef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ - Director timeline seeks cross the dateline along the short longitude arc. - Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. +- 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1ad836aba..054aff963 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4449,6 +4449,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. @@ -5326,8 +5331,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(recent-imagery): reject absent box edges instead of inventing zero degrees - -Reject absent, blank and boolean edges before numeric conversion while retaining valid finite numbers and numeric strings. Invalid boxes remain explicit invalid results instead of false geometry. From 6ffbf7f5244ccaaf8959118ad857efe8761e58a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:36:29 -0700 Subject: [PATCH 29/46] fix(imagery): refuse blank box edges at every conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share-link integers and Cesium radians were converted before normalizeBox checked them, so a blank edge still became 0°. Edges are now accepted only as numbers or non-blank numeric strings, which also refuses [] and {}. Tests cover all four edges through validateBox, dequantizeBox, boxFromRectangle and searchHls. --- CHANGELOG.md | 1 - .../imagery-box-missing-edge.test.mjs | 34 +++++++++++++++- src/layers/recentImagery/model.js | 40 +++++++++++-------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dddcdeef8..9ab3b03cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,6 @@ - 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. -- Recent Imagery refuses missing bounding-box edges instead of interpreting them as zero coordinates. - Recent Imagery’s exported `setBox` and `searchHls` reject missing, blank or boolean programmatic box edges instead of interpreting them as zero coordinates. diff --git a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs index 3e5a8e8a5..698bcaee5 100644 --- a/src/layers/recentImagery/imagery-box-missing-edge.test.mjs +++ b/src/layers/recentImagery/imagery-box-missing-edge.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { validateBox, boxCentre } from './model.js'; +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 }; @@ -14,3 +21,28 @@ test('missing imagery edges do not turn into invented zero coordinates', () => { 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 })); +}); diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 63c5ec16c..24ef54fc6 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -75,20 +75,23 @@ 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 edges = [box.west, box.south, box.east, box.north]; - if ( - edges.some( - (value) => - value == null || - typeof value === 'boolean' || - (typeof value === 'string' && !value.trim()), - ) - ) - return null; - const [west, south, east, north] = edges.map(Number); - if (![west, south, east, north].every(Number.isFinite)) return null; + const [west, south, east, north] = [ + box.west, + box.south, + box.east, + box.north, + ].map(edgeNumber); + if ([west, south, east, north].some((edge) => edge == null)) return null; return { west, south: Math.min(south, north), @@ -216,11 +219,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), }); } From 9f56e9df981eeb6f090c757d36bd081415edb34d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:47:56 -0400 Subject: [PATCH 30/46] fix(terrain): refresh cache recency when resolving warm batches --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/services/terrain-warm-batch-lru.test.mjs | 24 ++++++++++++++++++++ src/services/terrainHeights.js | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..e3075234c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. +- Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..246f3067c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(terrain): refresh cache recency when resolving warm batches + +Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs new file mode 100644 index 000000000..ac718b998 --- /dev/null +++ b/src/services/terrain-warm-batch-lru.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTerrainHeights } from './terrainHeights.js'; +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); +}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index a6502aeb5..124777150 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -273,7 +273,7 @@ export function createTerrainHeights({ const seenKeys = new Set(); const now = Date.now(); 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) && From 07757a3cae25956f362525432bf2b45bb1908bd3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:26 -0400 Subject: [PATCH 31/46] docs: address maintainer review for PR #1040 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 246f3067c..fcb2f3b3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,8 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +Warm batch lookups also promote their cached entries in the least-recently-used eviction order. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(terrain): refresh cache recency when resolving warm batches - -Use the existing LRU read path for batch lookups; provider requests, fallback cooldowns and bounded residency retain their current contracts. From b4aa61d6ef53982ed2cf34f0eea65cabf181c86d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 16:37:30 -0700 Subject: [PATCH 32/46] fix(terrain): keep a batch's warm points when its own fetches evict them A warm entry could be evicted by the same call's later fetches and then reported unresolved. The call now remembers warm hits as it does fetched ones. Moves the batch LRU test beside the existing cache tests and folds the docs note into the section's eviction paragraph. --- docs/CURRENT-STATE.md | 10 ++--- src/services/terrain-warm-batch-lru.test.mjs | 24 ------------ src/services/terrainHeights.js | 20 +++++----- src/services/terrainHeights.test.mjs | 41 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 41 deletions(-) delete mode 100644 src/services/terrain-warm-batch-lru.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fcb2f3b3f..d28fee7e1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,19 +1192,17 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound -Warm batch lookups also promote their cached entries in the least-recently-used eviction order. - The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated 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. diff --git a/src/services/terrain-warm-batch-lru.test.mjs b/src/services/terrain-warm-batch-lru.test.mjs deleted file mode 100644 index ac718b998..000000000 --- a/src/services/terrain-warm-batch-lru.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTerrainHeights } from './terrainHeights.js'; -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); -}); diff --git a/src/services/terrainHeights.js b/src/services/terrainHeights.js index 124777150..f84a4413f 100644 --- a/src/services/terrainHeights.js +++ b/src/services/terrainHeights.js @@ -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 = 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. diff --git a/src/services/terrainHeights.test.mjs b/src/services/terrainHeights.test.mjs index c9884e89c..5e80b9be2 100644 --- a/src/services/terrainHeights.test.mjs +++ b/src/services/terrainHeights.test.mjs @@ -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({ From d861f24d3e4456de567590ba253cd6a5b84a68f3 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:11:57 -0400 Subject: [PATCH 33/46] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3075234c..80d2a4544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. + - Frequently reused terrain batches retain their cached heights ahead of unused cells. - Treat explicitly current, complete empty vessel coverage as a successful update, From 8b68ff8412747986895811912ea4d5e26c4c0a9c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:44 -0400 Subject: [PATCH 34/46] fix(firms): reject missing and out-of-range detection coordinates --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++++ src/data/firms-coordinate-evidence.test.mjs | 31 +++++++++++++++++++++ src/data/firmsAdapt.js | 13 ++++++++- src/data/firmsCsv.js | 10 ++++++- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/data/firms-coordinate-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..446192c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. +- Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..967fbda64 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(firms): reject missing and out-of-range detection coordinates + +Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs new file mode 100644 index 000000000..494b38c18 --- /dev/null +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -0,0 +1,31 @@ +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], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 2fc909bce..b8f07ec2d 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -21,7 +21,18 @@ 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 ( + ![record?.lat, record?.lon].every( + (value) => + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== ''), + ) || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; fires.push({ index: fires.length, lat, diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..c89d10a0a 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,7 +105,15 @@ 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; + 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, From 5a14a06a52ca70517b1a6199177d1b4ea2a47f0c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:28 -0400 Subject: [PATCH 35/46] docs: address maintainer review for PR #1044 --- docs/CURRENT-STATE.md | 7 ++----- src/data/firmsAdapt.js | 14 +++++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 967fbda64..4c33b1744 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,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 @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(firms): reject missing and out-of-range detection coordinates - -Require explicit numeric coordinate evidence and WGS84 ranges in both ingestion paths, preserving genuine zero coordinates and sequential admitted detection indices. diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index b8f07ec2d..3e3f27631 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -22,11 +22,8 @@ export function adaptFirmsRecords(records) { const lat = Number(record?.lat); const lon = Number(record?.lon); if ( - ![record?.lat, record?.lon].every( - (value) => - typeof value === 'number' || - (typeof value === 'string' && value.trim() !== ''), - ) || + !hasCoordinateValue(record?.lat) || + !hasCoordinateValue(record?.lon) || !Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || @@ -121,3 +118,10 @@ function finiteNumber(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } + +function hasCoordinateValue(value) { + return ( + typeof value === 'number' || + (typeof value === 'string' && value.trim() !== '') + ); +} From e26a715b04968cac14646735232ce75b32716793 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:33 -0400 Subject: [PATCH 36/46] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 446192c88..077d7c5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. + - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. - Treat explicitly current, complete empty vessel coverage as a successful update, From cd7c23b36fcee32f646723456bd1a7d163b86d9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:49:47 -0400 Subject: [PATCH 37/46] fix(director): reject cancellation during the final asset stream read --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++ .../packs/asset-stream-cancellation.test.mjs | 42 +++++++++++++++++++ src/director/packs/source.js | 1 + 4 files changed, 49 insertions(+) create mode 100644 src/director/packs/asset-stream-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..a7f8ca53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. +- Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..20e346aad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): reject cancellation during the final asset stream read + +Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs new file mode 100644 index 000000000..cd4f2490d --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,42 @@ +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); + } +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 4828827af..052581a70 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -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'); From 04b712910d3e4793180f4bd66b1f198ebe704419 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:30 -0400 Subject: [PATCH 38/46] docs: address maintainer review for PR #1046 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 20e346aad..fae0cc8bf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,6 +396,8 @@ 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. + 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 @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(director): reject cancellation during the final asset stream read - -Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. From 5889d353272723a75e8b0bfaacbc2085873e15f7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:51:10 -0400 Subject: [PATCH 39/46] fix(maps): stop delivering settled state to detached subscribers --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 ++ src/maps/controller.js | 1 + src/maps/map-subscription-ownership.test.mjs | 55 ++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/maps/map-subscription-ownership.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..362eb93e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..558963496 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(maps): stop delivering settled state to detached subscribers + +Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index e595ede26..d51b67082 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,6 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { + if (this._destroyed || !this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs new file mode 100644 index 000000000..638de65a2 --- /dev/null +++ b/src/maps/map-subscription-ownership.test.mjs @@ -0,0 +1,55 @@ +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']); +}); From bbbdd0aeba19f9f73a0ba43db4a0a2d7c84b5b35 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:31 -0400 Subject: [PATCH 40/46] docs: address maintainer review for PR #1047 --- docs/CURRENT-STATE.md | 7 ++----- src/maps/controller.js | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 558963496..ef3720370 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5200,6 +5200,8 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination +A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. + Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(maps): stop delivering settled state to detached subscribers - -Recheck listener ownership and controller lifetime before each settled-state callback. Still-active subscribers keep their ordering and exception isolation. diff --git a/src/maps/controller.js b/src/maps/controller.js index d51b67082..6dd2720f7 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -142,7 +142,7 @@ export class MapSourceController { if (this._destroyed || !this._subscribers.size) return; const state = this.getState(); for (const listener of [...this._subscribers]) { - if (this._destroyed || !this._subscribers.has(listener)) continue; + if (!this._subscribers.has(listener)) continue; try { listener(state); } catch (error) { From e225f0851b63f014f40233f9e611c79af215e394 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:15 -0400 Subject: [PATCH 41/46] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362eb93e7..168d5b9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. - Treat explicitly current, complete empty vessel coverage as a successful update, From 1f83897d7719977a5405045e9761e8554ff84315 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:26 -0400 Subject: [PATCH 42/46] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f8ca53d..44a6e5908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - 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. + - Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, From 6a7b89e436f282356375f6dff667ec8ffb0c1a30 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:21:57 -0700 Subject: [PATCH 43/46] test(firms): cover both coordinate bounds and blank values on each axis Documents the adapter's skip rule and cross-references the two checks, which sit in separate packages (provider parser and fire layer). --- src/data/firms-coordinate-evidence.test.mjs | 38 +++++++++++++++++++++ src/data/firmsAdapt.js | 5 +-- src/data/firmsCsv.js | 2 ++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/data/firms-coordinate-evidence.test.mjs b/src/data/firms-coordinate-evidence.test.mjs index 494b38c18..d60a7ee37 100644 --- a/src/data/firms-coordinate-evidence.test.mjs +++ b/src/data/firms-coordinate-evidence.test.mjs @@ -29,3 +29,41 @@ test('missing positions do not become fire detections at zero coordinates', () = ], ); }); + +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], + ], + ); +}); diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 3e3f27631..312838e80 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -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} records - /api/firms `fires` array. @@ -119,6 +119,7 @@ function finiteNumber(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' || diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index c89d10a0a..aafeac50b 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -105,6 +105,8 @@ export function parseFirmsCsv(text) { if (parts.length < header.length) continue; // malformed row — skip const lat = Number(parts[iLat]); const lon = Number(parts[iLon]); + // 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() || From bf4edb5bdb7697372109cf262c377c6b95c98512 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:12 -0700 Subject: [PATCH 44/46] fix(director): discard an asset cancelled during reader cleanup The finally block awaits reader.cancel(), so a cancel arriving there still let the bytes return. Rechecks the signal after cleanup, with a test that keeps the caller's abort reason, and wraps the current-state note. --- docs/CURRENT-STATE.md | 4 +- .../packs/asset-stream-cancellation.test.mjs | 37 +++++++++++++++++++ src/director/packs/source.js | 2 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a70f30e9..de4ca26ca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,7 +396,9 @@ 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. +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 diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs index cd4f2490d..eb67244ff 100644 --- a/src/director/packs/asset-stream-cancellation.test.mjs +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -40,3 +40,40 @@ test('cancellation during the terminating read cannot return a scene asset', asy 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); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 052581a70..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -51,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) { From b9fa0eedcc2367a018f501688a9bd6d4e86bb956 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:46 -0700 Subject: [PATCH 45/46] fix(maps): stop all delivery once the controller is destroyed A listener could destroy the controller and re-subscribe one still pending in the snapshot, which then ran. Delivery now stops at destruction and a destroyed controller takes no listeners. Adds ordering tests for adds, duplicates and re-adds during delivery, and moves the current-state note after the section intro. --- docs/CURRENT-STATE.md | 6 ++-- src/maps/controller.js | 4 ++- src/maps/map-subscription-ownership.test.mjs | 34 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8ecd0eae8..8474257b6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5209,8 +5209,6 @@ the project's MIT license; see `DATA_SOURCES.md`. ## Map source factories and coordination -A subscriber removed, or a controller destroyed, during a settled-state delivery receives no further callbacks from that delivery. - Map selection is composed from separate imagery, terrain and 3D factories. The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and OSM, including their setup guidance and existing preset/share IDs. Esri still @@ -5218,6 +5216,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 diff --git a/src/maps/controller.js b/src/maps/controller.js index 6dd2720f7..2b2ea462f 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -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,7 @@ 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); diff --git a/src/maps/map-subscription-ownership.test.mjs b/src/maps/map-subscription-ownership.test.mjs index 638de65a2..cf0708c09 100644 --- a/src/maps/map-subscription-ownership.test.mjs +++ b/src/maps/map-subscription-ownership.test.mjs @@ -53,3 +53,37 @@ test('destroying the controller during delivery stops remaining listeners', asyn 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(); +}); From fd5a1ef8569a313b249af271fe3d2e6f18817cfa Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:54:17 -0400 Subject: [PATCH 46/46] fix(tools): keep results when a timestamp is outside Date's range (#1045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isoTime returned toISOString() for any finite number, which throws past ±8.64e15 ms, so one bad record (e.g. a bike-share last_reported) failed the whole tool answer. Such timestamps are now null. The voice analyst's result rows had the same conversion and get the same guard. Co-authored-by: Sameh Khamis --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 2 + src/tools/results.js | 4 +- src/tools/tool-timestamp-range.test.mjs | 59 +++++++++++++++++++++++++ src/voice/gevActions.js | 5 ++- src/voice/gevActions.test.mjs | 20 +++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/tools/tool-timestamp-range.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6051fb3aa..b72aeefd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- 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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6ac31dd73..251b71a7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927). 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, diff --git a/src/tools/results.js b/src/tools/results.js index 51ed82bbb..f9af7af3d 100644 --- a/src/tools/results.js +++ b/src/tools/results.js @@ -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". */ diff --git a/src/tools/tool-timestamp-range.test.mjs b/src/tools/tool-timestamp-range.test.mjs new file mode 100644 index 000000000..7aeee737b --- /dev/null +++ b/src/tools/tool-timestamp-range.test.mjs @@ -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); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9e1e183d0..267f633ff 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4808,8 +4808,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; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 3d0e35b3e..2a80b829c 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -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 }) });