From 5980a085337907e20444887226f649168749b3ee Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:36 +0100 Subject: [PATCH 01/25] fix(overpass): reserve singleflight before disk cache reads --- docs/CURRENT-STATE.md | 6 ++ server/providers/overpass.js | 93 +++++++++++------------------- server/providers/overpass/cache.js | 27 ++++++--- src/data/overpassProxy.test.mjs | 41 ++++++++++++- 4 files changed, 96 insertions(+), 71 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f59f6c5d9..92f7ecd18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Overpass reserves each normalized query before its disk-cache lookup. The same +reservation covers upstream quota admission, concurrency, fetch and cache +publication; duplicate callers join the complete transaction and share stale +fallback on failure. Settled and rejected reservations are removed only while +they still own their map entry. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/server/providers/overpass.js b/server/providers/overpass.js index 3b5a2e575..a9726f22b 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) { readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), allowUpstream: () => _overpassRateLimiter(clientKey(req)), + loadUpstream: async () => { + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) + return { + status: 503, + contentType: 'application/json', + retryAfterMs: 2000, + body: JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + }; + _overpassConcurrent++; + try { + const payload = await fetchOverpassPayload(safeBody); + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + } finally { + _overpassConcurrent--; + } + }, }); if (preflight.source === 'RATE_LIMITED') { res.writeHead(429, { @@ -173,71 +197,22 @@ function overpassProxy({ routing = {} } = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - if (preflight.source !== 'UPSTREAM') { - // A coalesced caller sees the same failure as the original request - // and must get the same last-good fallback, not the raw refusal. - if (!overpassPayloadIsData(preflight.payload)) { - const stale = await readStaleOverpass(cacheKey); - if (stale) { - sendOverpassResponse(res, stale, 'STALE'); - return; - } - } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } - sendOverpassResponse(res, preflight.payload, preflight.source); - return; - } - - // From here onward the request is genuinely upstream-bound and has - // consumed one local limiter slot. Cache and dedupe hits above do not. - if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { - res.writeHead(503, { - 'Content-Type': 'application/json', - 'Retry-After': '2', - }); - res.end( - JSON.stringify({ - error: 'Overpass proxy busy — try again shortly', - }), - ); - return; - } - _overpassConcurrent += 1; - const requestPromise = fetchOverpassPayload(safeBody) - .then((payload) => { - // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's - // refusal was written to memory AND disk — and boundary-class - // queries hold a month-long TTL, so a single 406 outlived the - // outage that caused it. - if (overpassPayloadIsData(payload)) { - const entry = { ...payload, cachedAt: Date.now() }; - _overpassCache.set(cacheKey, entry); - trimOverpassCache(); - writeOverpassDisk(cacheKey, entry); - } - return payload; - }) - .finally(() => { - _overpassConcurrent -= 1; - _overpassInFlight.delete(cacheKey); - }); - - _overpassInFlight.set(cacheKey, requestPromise); - const payload = await requestPromise; - // Degraded upstream (rate-limited on every mirror / 5xx / runtime - // error): last-good roads beat an empty layer — serve stale from - // memory or disk at ANY age before surfacing the failure. - if (!overpassPayloadIsData(payload)) { + if (!overpassPayloadIsData(preflight.payload)) { const stale = await readStaleOverpass(cacheKey); if (stale) { sendOverpassResponse(res, stale, 'STALE'); return; } } - sendOverpassResponse(res, payload, 'MISS'); + if (preflight.source === 'DISK') { + _overpassCache.set(cacheKey, preflight.payload); + trimOverpassCache(); + } + sendOverpassResponse( + res, + preflight.payload, + preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source, + ); } catch (e) { // Every mirror threw (network-level). Same serve-stale rule. const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 442f599a6..5e88c3f19 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -1,3 +1,4 @@ +import { coalesceProxyRequest } from '../common/http.js'; import { isOverpassBoundaryQuery } from './query.js'; import { OVERPASS_BOUNDARY_DISK_TTL_MS, @@ -87,6 +88,7 @@ function writeOverpassDisk(cacheKey, payload) { * @param {Map>} options.inFlight * @param {()=>Promise} options.readDisk * @param {()=>boolean} options.allowUpstream + * @param {()=>Promise} options.loadUpstream Fetch and publish the admitted result. * @param {number} [options.now] * @param {number} [options.cacheMs] * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} @@ -97,6 +99,7 @@ async function resolveOverpassPreflight({ inFlight, readDisk, allowUpstream, + loadUpstream = async () => null, now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { @@ -104,15 +107,21 @@ async function resolveOverpassPreflight({ if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached }; - const pending = inFlight.get(cacheKey); - if (pending) return { source: 'INFLIGHT', payload: await pending }; - - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; - - return allowUpstream() - ? { source: 'UPSTREAM', payload: null } - : { source: 'RATE_LIMITED', payload: null }; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + return request.shared && result.source !== 'RATE_LIMITED' + ? { ...result, source: 'INFLIGHT' } + : result; } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index f161eb72e..7f9b117ae 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const memoryHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, fresh]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const inFlightHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk'); assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota'); + const diskMemory = new Map(); const diskHit = await resolveOverpassPreflight({ cacheKey: key, - memoryCache: new Map(), + memoryCache: diskMemory, inFlight: new Map(), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, @@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskHit.payload, disk); assert.equal(diskReads, 1); assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota'); + const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream }); + assert.equal(publishedDisk.source, 'HIT'); const upstreamMiss = await resolveOverpassPreflight({ cacheKey: key, @@ -214,3 +217,35 @@ function pointSegDistDeg(p, a, b) { const t = c1 / c2; return Math.hypot(wx - t * vx, wy - t * vy); } + + +test('cold disk lookup is single-flight through upstream publication and quota admission', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, admissions = 0, upstreams = 0; + const payload = { status: 200, body: '{"elements":[]}' }; + const options = { + cacheKey: 'cold', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return disk; }, + allowUpstream: () => { admissions++; return true; }, + loadUpstream: async () => { upstreams++; return payload; }, + }; + const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options)); + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const results = await Promise.all(pending); + assert.equal(reads, 1); + assert.equal(admissions, 1); + assert.equal(upstreams, 1); + assert.ok(results.every((result) => result.payload === payload)); + assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']); + assert.equal(inFlight.size, 0); + const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } }; + const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]); + assert.ok(rejected.every((result) => result.status === 'rejected')); + assert.equal(inFlight.size, 0); + await resolveOverpassPreflight({ ...options, readDisk: async () => null }); + assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); +}); From 133d82621eb31aa1d939cf008cb992dcfb0dc48d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:53 +0100 Subject: [PATCH 02/25] docs: record overpass fix --- changelog.d/1278-overpass-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1278-overpass-issue-fix.md diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md new file mode 100644 index 000000000..4b028f046 --- /dev/null +++ b/changelog.d/1278-overpass-issue-fix.md @@ -0,0 +1 @@ +- Reserve singleflight before disk cache reads (asturksever, #1278). From 08e90e818261326b1c52ba33fba8c5b5f006f3ad Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:19 +0100 Subject: [PATCH 03/25] fix(missions): throttle hover picks and clear stale cursors --- docs/CURRENT-STATE.md | 2 + src/layers/launches/hover.test.mjs | 91 ++++++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 90 ++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/layers/launches/hover.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..135543f3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..83a370f26 --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { + let at = 0, + picks = 0, + enabled = true; + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_n, fn) => listeners.add(fn), + removeEventListener: (_n, fn) => listeners.delete(fn), + }; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: {} }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: doc, + pointerFree: () => true, + }); + for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + viewer.camera.moveStart.raiseEvent(); + at = 500; + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + assert.equal(viewer.scene.canvas.style.cursor, ''); + viewer.camera.moveEnd.raiseEvent(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + doc.hidden = true; + for (const fn of listeners) fn(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + assert.equal(viewer.scene.canvas.style.cursor, ''); + doc.hidden = false; + enabled = false; + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + hover.destroy(); + assert.equal(listeners.size, 0); + assert.equal(viewer.camera.moveStart.numberOfListeners, 0); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); +}); + +test('mission hover preserves the cursor of a pointer-owning tool', () => { + let free = true, + picks = 0; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => true, + entityLaunchId: () => true, + documentRef: null, + pointerFree: () => free, + }); + hover.move({ endPosition: {} }); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + free = false; + viewer.scene.canvas.style.cursor = 'crosshair'; + hover.move({ endPosition: {} }); + viewer.camera.moveStart.raiseEvent(); + hover.destroy(); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'crosshair'); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 75b0276a3..4d4962242 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,75 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +/** Bound hover picking independently of precise click selection. */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const reset = () => { + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + const removeStart = viewer.camera?.moveStart?.addEventListener(start); + const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); + documentRef?.addEventListener?.('visibilitychange', visibility); + return { + move(movement) { + if ( + !enabled() || + moving || + documentRef?.hidden || + documentRef?.visibilityState === 'hidden' || + !pointerFree() + ) { + reset(); + return; + } + const at = now(); + if (at - lastPick < 120) return; + lastPick = at; + const found = viewer.scene + .drillPick(movement.endPosition, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +77,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -45,14 +115,15 @@ export function createLifecycle({ layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - layerState._moveHandler.setInputAction((movement) => { - if (!layerState._enabled || !layerState._dataSource?.show) return; - const missionEntity = viewer.scene - .drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => parts.selection.entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + hover = createLaunchHover({ + viewer, + enabled: () => layerState._enabled && layerState._dataSource?.show, + entityLaunchId: parts.selection.entityLaunchId, + }); + layerState._moveHandler.setInputAction( + hover.move, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -100,6 +171,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -122,6 +194,8 @@ export function createLifecycle({ }, async destroy(viewer) { + hover?.destroy(); + hover = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; From bc0c4b84897d7d1fd1dc77c4bf3d843a4e5cea62 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:33 +0100 Subject: [PATCH 04/25] docs: record launch fix --- changelog.d/1279-launch-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1279-launch-issue-fix.md diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md new file mode 100644 index 000000000..7005c53cd --- /dev/null +++ b/changelog.d/1279-launch-issue-fix.md @@ -0,0 +1 @@ +- Throttle hover picks and clear stale cursors (asturksever, #1279). From ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH 05/25] fix(layers): pause periodic network updates in hidden tabs --- docs/CURRENT-STATE.md | 2 ++ src/app/data.js | 1 + src/data/lifecycle.js | 57 +++++++++++++++++++++++---------- src/data/manager.js | 2 +- src/data/manager.test.mjs | 31 ++++++++++++++++++ src/layers/transit/lifecycle.js | 1 + src/layers/transit/viewport.js | 5 +++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..508b6264d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..01aaf6124 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) { /** * LayerLifecycle — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. + * Hosts may inject a visibilityTarget with hidden/visibilityState and event + * listener methods. Without one, periodic work retains its headless behavior. */ export class LayerLifecycle { constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + visibilityTarget = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._visibilityTarget = visibilityTarget; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -528,6 +535,13 @@ export class LayerLifecycle { } } + _stopUpdateLoop(entry) { + clearInterval(entry.intervalId); + entry.intervalId = null; + entry.removeVisibilityListener?.(); + entry.removeVisibilityListener = null; + } + _armUpdateLoop(layerId, entry) { const configuredRefreshInterval = Number(entry.module.refreshInterval); const updateInterval = Number(entry.module.updateInterval); @@ -538,9 +552,25 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { + const visibilityDocument = this._visibilityTarget; + let dueAt = Date.now() + refreshInterval; + const tick = () => { + if ( + visibilityDocument?.hidden || + visibilityDocument?.visibilityState === 'hidden' || + !entry.enabled || + entry.destroying || + entry.refreshing || + Date.now() < dueAt + ) + return; + dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); + }; + entry.intervalId = setInterval(tick, refreshInterval); + visibilityDocument?.addEventListener?.('visibilitychange', tick); + entry.removeVisibilityListener = () => + visibilityDocument?.removeEventListener?.('visibilitychange', tick); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -772,8 +802,7 @@ export class LayerLifecycle { entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } this._publishActivity({ type: 'status' }); if (!compensated) { @@ -833,8 +862,7 @@ export class LayerLifecycle { } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; entry.lifecycleUncertain = false; @@ -845,8 +873,7 @@ export class LayerLifecycle { let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second @@ -876,8 +903,7 @@ export class LayerLifecycle { // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } await abortCleanup; let cleanupConfirmed = false; @@ -918,8 +944,7 @@ export class LayerLifecycle { }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } let cleanupConfirmed = false; try { @@ -1029,8 +1054,7 @@ export class LayerLifecycle { // Always clear any stale interval before assigning a new one, so we never // orphan a running timer and end up double-polling. if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Manager-owned periodic refresh work has one normalized loading/error @@ -2158,8 +2182,7 @@ export class LayerLifecycle { return false; } if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; } diff --git a/src/data/manager.js b/src/data/manager.js index b719b5934..79bb7d9ae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js'; /** Compatibility facade for callers that construct a manager with its panel. */ export class DataLayerManager extends LayerLifecycle { constructor(viewer, options) { - super(viewer, options); + super(viewer, { visibilityTarget: globalThis.document, ...options }); this._presentation = new LayerPresentation(this); } buildTogglePanel(container) { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..68a0644f1 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3492,3 +3492,34 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async lifecycle.refreshLayerStats(); assert.deepEqual(changes, ['status']); }); + +test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('hidden-poll'); + manager.register(module); + await manager.setEnabled('hidden-poll', true); + assert.equal(calls.update, 1); + doc.hidden = true; + t.mock.timers.tick(10000); + assert.equal(calls.update, 1); + assert.equal(await manager.refreshLayer('hidden-poll'), true); + assert.equal(calls.update, 2, 'explicit refresh remains allowed'); + doc.hidden = false; + for (const fn of listeners) fn(); + for (const fn of listeners) fn(); + assert.equal(calls.update, 3); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(10000); + assert.equal(calls.update, 3); +}); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); From eabff9005d894454b22a66144a0582fe195aa3e7 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:26:21 +0100 Subject: [PATCH 06/25] docs: record polling fix --- changelog.d/1282-polling-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1282-polling-issue-fix.md diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..82f9a0fba --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1 @@ +- Pause periodic network updates in hidden tabs (asturksever, #1282). From e4955f351ff8c7068748b22e9673e7b61edd7d92 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:07:13 +0100 Subject: [PATCH 07/25] test: qualify Transit visibility and city feed ownership --- changelog.d/1282-polling-issue-fix.md | 2 ++ scripts/qa-transit-browser.mjs | 5 ++++ scripts/qa-transit-controls.mjs | 5 ++++ scripts/qa-transit.mjs | 42 ++++++++++++++++++++------- src/tooling/transitQa.test.mjs | 13 +++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 82f9a0fba..c7b0dec0a 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1 +1,3 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0c66098 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -266,6 +266,7 @@ const browser = await puppeteer.launch({ executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -391,22 +392,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -428,6 +449,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 51e4d5c44..634538c2a 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { getTransitFeed } from '../data/transitFeeds.js'; import { chooseLiveCities, selectStyle, @@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', () const cities = chooseLiveCities(now); assert.ok(cities.length >= 1); for (const city of cities) { + const feed = getTransitFeed(city.feedId); + assert.ok(feed, `${city.id} has a registered feed`); + const distanceKm = + 111.32 * + Math.hypot( + feed.center.lat - city.lat, + (feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180), + ); + assert.ok( + distanceKm < feed.loadRadiusKm, + `${city.id} is inside its feed's admission region`, + ); const local = Number( new Intl.DateTimeFormat('en', { timeZone: city.zone, From 2c8b3593d1af5ae2291c178f9105da0a01060223 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:19:11 +0100 Subject: [PATCH 08/25] test: observe a poll transition in the Transit anchor probe --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-transit.mjs | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index c7b0dec0a..f3aede4e4 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,3 +1,4 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index fb0c66098..628983e74 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -40,6 +40,7 @@ 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'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -1631,6 +1632,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1647,6 +1649,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1692,7 +1701,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1700,6 +1717,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1714,7 +1732,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1735,8 +1753,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( From 5b9c726f33e7eb6e9af400f981f42ae2ef243628 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:57 +0100 Subject: [PATCH 09/25] test: prove hidden-tab polling in a real browser --- changelog.d/1282-polling-issue-fix.md | 2 + docs/CURRENT-STATE.md | 2 + scripts/format-scope.json | 3 +- scripts/qa-layer-visibility.mjs | 124 ++++++++++++++++++++++++++ scripts/qa-transit.mjs | 4 + 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-layer-visibility.mjs diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index f3aede4e4..4af89eeee 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -2,3 +2,5 @@ - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 508b6264d..3eacee1c6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4002,6 +4002,8 @@ animation. Polls, surface completions and trail fades request individual renders Disable clears vehicles, detection references, requests, timers and holds; destruction also releases collections and registrations. +The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped. + **Acceptance harness.** Run against an already running development server: ```sh diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..b894cbe9a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "scripts/qa-layer-visibility.mjs" ] diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 628983e74..47eafc94d 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -275,6 +275,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = []; From 72f1625257d14702e5a911958ab72c70264ee3e5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:43:01 +0100 Subject: [PATCH 10/25] test: avoid concurrent format-scope append conflicts --- scripts/format-scope.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b894cbe9a..92bd228ba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -4,6 +4,7 @@ "scripts/format-scope.json", "scripts/package-boundaries.json", "scripts/format.mjs", + "scripts/qa-layer-visibility.mjs", "scripts/next-layer-state-token.mjs", "scripts/check-layer-state-tokens.mjs", "scripts/qa-layer-token-twochar.mjs", @@ -1070,6 +1071,5 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs", - "scripts/qa-layer-visibility.mjs" + "src/terrainHeightsBounds.test.mjs" ] From 56be69d065c4e77d46ec034075327155eb520940 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:33 +0100 Subject: [PATCH 11/25] test: restore minimized Transit windows before screenshots --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-chrome.mjs | 27 +++++++++++++ scripts/qa-transit-recovery.mjs | 4 +- scripts/qa-transit-scenes.mjs | 13 +++--- scripts/qa-transit.mjs | 14 ++++--- src/qaChrome.test.mjs | 57 ++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 4af89eeee..9931385bf 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -4,3 +4,4 @@ - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). - Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). - Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 47eafc94d..943e39df5 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,7 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } 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'; @@ -294,11 +294,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); From 07d6417446f4e1f724e392147bacce73713f8093 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:22:42 +0100 Subject: [PATCH 12/25] Realign periodic polling to the deadline after a visibility catch-up An overdue refresh on return to a visible tab advanced the due time but kept the old setInterval phase, so the next phase tick was skipped and the gap could approach two intervals (also after a failed resume refresh). The catch-up now re-arms the interval at the new deadline. Interval ticks no longer compare against the due time, since the phase and the deadline share one origin. Overlap prevention and teardown are unchanged, and a stale listener cannot re-arm a stopped loop. Tests cover the next poll one interval after a between-ticks resume, with a successful and a failed catch-up refresh. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1282-polling-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/data/lifecycle.js | 48 +++++++++++++++------- src/data/manager.test.mjs | 58 +++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 9931385bf..98885fc20 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,4 +1,4 @@ -- Pause periodic network updates in hidden tabs (asturksever, #1282). +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8b3827634..3cb2493e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 01aaf6124..c8946e7bb 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -553,24 +553,44 @@ export class LayerLifecycle { : 0; if (refreshInterval > 0) { const visibilityDocument = this._visibilityTarget; - let dueAt = Date.now() + refreshInterval; - const tick = () => { - if ( - visibilityDocument?.hidden || - visibilityDocument?.visibilityState === 'hidden' || - !entry.enabled || - entry.destroying || - entry.refreshing || - Date.now() < dueAt - ) - return; + let intervalId = null; + let dueAt = 0; + const canPoll = () => + !visibilityDocument?.hidden && + visibilityDocument?.visibilityState !== 'hidden' && + entry.enabled && + !entry.destroying && + !entry.refreshing && + entry.intervalId === intervalId; + // dueAt and the interval phase share one origin, so every interval tick + // is a due poll. A visibility catch-up re-arms the interval at the new + // deadline; otherwise the old phase would skip its next tick. + const schedule = () => { + clearInterval(intervalId); + dueAt = Date.now() + refreshInterval; + intervalId = setInterval(onInterval, refreshInterval); + entry.intervalId = intervalId; + }; + const onInterval = () => { + if (!canPoll()) return; dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); }; - entry.intervalId = setInterval(tick, refreshInterval); - visibilityDocument?.addEventListener?.('visibilitychange', tick); + const onVisibilityChange = () => { + if (!canPoll() || Date.now() < dueAt) return; + schedule(); + void this._runPeriodicUpdate(layerId, entry); + }; + schedule(); + visibilityDocument?.addEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); entry.removeVisibilityListener = () => - visibilityDocument?.removeEventListener?.('visibilitychange', tick); + visibilityDocument?.removeEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 68a0644f1..b1384a2de 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3523,3 +3523,61 @@ test('periodic polls pause hidden, catch up once when due, and dispose visibilit t.mock.timers.tick(10000); assert.equal(calls.update, 3); }); + +async function resumeBetweenTicks(t, { failResume = false } = {}) { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('resume-poll'); + const updates = []; + module.update = async () => { + calls.update++; + updates.push(Date.now()); + await Promise.resolve(); + if (failResume && updates.length === 2) throw new Error('resume failed'); + }; + manager.register(module); + await manager.setEnabled('resume-poll', true); + assert.deepEqual(updates, [1000]); + doc.hidden = true; + t.mock.timers.tick(1000); // 2000: due tick skipped while hidden + t.mock.timers.tick(500); // 2500: return between ticks + doc.hidden = false; + for (const fn of listeners) fn(); + assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError; + if (failResume) assert.equal(managerRefreshError, 'resume failed'); + else assert.equal(managerRefreshError, null); + t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire + assert.deepEqual(updates, [1000, 2500]); + t.mock.timers.tick(1); // 3500: one interval after the resume poll + assert.deepEqual(updates, [1000, 2500, 3500]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(1000); + assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval'); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(5000); + assert.equal(updates.length, 4); +} + +test('periodic polls realign to the deadline after a between-ticks resume', async (t) => { + await resumeBetweenTicks(t); +}); + +test('periodic polls realign to the deadline after a failed resume refresh', async (t) => { + await resumeBetweenTicks(t, { failResume: true }); +}); From e60caab1802c313683c826340beb783f51313ec5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:23:07 +0100 Subject: [PATCH 13/25] Run a trailing hover check for the last suppressed pointer position A move inside the 120 ms hover throttle window was dropped, so a pointer that stopped right after a pick kept a stale cursor (pointer over empty space, or default over a mission) until the next move. Keep a copy of the last suppressed position (Cesium reuses the movement object) and run one trailing check when the window ends. The check rechecks hover eligibility and is cancelled by reset, disable, camera motion, and destroy. Tests cover a stopped pointer in both directions without another event, the copied position, one timer at a time, cancellation, and the eligibility recheck. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1279-launch-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/layers/launches/hover.test.mjs | 141 +++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 75 ++++++++++---- 4 files changed, 201 insertions(+), 19 deletions(-) diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md index 7005c53cd..ec451e7ae 100644 --- a/changelog.d/1279-launch-issue-fix.md +++ b/changelog.d/1279-launch-issue-fix.md @@ -1 +1 @@ -- Throttle hover picks and clear stale cursors (asturksever, #1279). +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1cd57febe..8405d4d50 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. A move inside the 120 ms window keeps a copy of its position and runs one trailing check when the window ends, so a pointer that stops is still resolved; the trailing check rechecks eligibility and is cancelled by reset, disable, camera motion, and destroy. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs index 83a370f26..d09e5168b 100644 --- a/src/layers/launches/hover.test.mjs +++ b/src/layers/launches/hover.test.mjs @@ -3,6 +3,146 @@ import assert from 'node:assert/strict'; import { Event } from 'cesium'; import { createLaunchHover } from './lifecycle.js'; +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + const api = { + setTimer(fn, ms) { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimer(id) { + timers.delete(id); + }, + pending: () => [...timers.values()], + flush() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, timer] of due) timer.fn(); + }, + }; + return api; +} + +function hoverRig({ overMarker }) { + let at = 0, + picks = 0, + enabled = true, + free = true; + const timers = fakeTimers(); + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick(position) { + picks++; + return overMarker(position) ? [{ id: 'launch' }] : []; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: { hidden: false }, + pointerFree: () => free, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + return { + hover, + timers, + viewer, + cursor: () => viewer.scene.canvas.style.cursor, + picks: () => picks, + setTime: (value) => (at = value), + setEnabled: (value) => (enabled = value), + setFree: (value) => (free = value), + }; +} + +const onMarker = (position) => position?.x === 1; +const marker = () => ({ endPosition: { x: 1, y: 1 } }); +const empty = () => ({ endPosition: { x: 9, y: 9 } }); + +test('mission hover runs one trailing check for a pointer that stops on empty space', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + assert.equal(rig.cursor(), 'pointer'); + rig.setTime(50); + const movement = empty(); + rig.hover.move(movement); + // Cesium reuses its movement object; the trailing check must use a copy. + movement.endPosition.x = 1; + assert.equal(rig.picks(), 1); + assert.deepEqual( + rig.timers.pending().map((timer) => timer.ms), + [70], + ); + rig.setTime(80); + rig.hover.move(empty()); + assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time'); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), ''); + assert.equal(rig.timers.pending().length, 0); + rig.hover.destroy(); +}); + +test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + assert.equal(rig.cursor(), ''); + rig.setTime(50); + rig.hover.move(marker()); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), 'pointer'); + rig.hover.destroy(); + assert.equal(rig.cursor(), ''); +}); + +test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => { + for (const cancel of [ + (rig) => rig.hover.reset(), + (rig) => rig.hover.destroy(), + (rig) => rig.viewer.camera.moveStart.raiseEvent(), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + rig.setTime(50); + rig.hover.move(marker()); + assert.equal(rig.timers.pending().length, 1); + cancel(rig); + assert.equal(rig.timers.pending().length, 0); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover trailing check rechecks eligibility before picking', () => { + for (const block of [ + (rig) => rig.setEnabled(false), + (rig) => rig.setFree(false), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + rig.setTime(50); + rig.hover.move(marker()); + block(rig); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { let at = 0, picks = 0, @@ -30,6 +170,7 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners', now: () => at, documentRef: doc, pointerFree: () => true, + ...fakeTimers(), }); for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); assert.equal(picks, 1); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 4d4962242..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,7 +1,13 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Bound hover picking independently of precise click selection. */ +const HOVER_PICK_INTERVAL_MS = 120; + +/** + * Bound hover picking independently of precise click selection. A move inside + * the throttle window is not dropped: its position is kept and one trailing + * check runs when the window ends, so a pointer that stops is still resolved. + */ export function createLaunchHover({ viewer, enabled, @@ -9,16 +15,26 @@ export function createLaunchHover({ now = () => performance.now(), documentRef = globalThis.document, pointerFree = isPointerFree, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, }) { let moving = false; let lastPick = -Infinity; let ownsCursor = false; + let pendingPosition = null; + let trailingTimer = null; const clearCursor = () => { if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') viewer.scene.canvas.style.cursor = ''; ownsCursor = false; }; + const cancelTrailing = () => { + if (trailingTimer !== null) clearTimer(trailingTimer); + trailingTimer = null; + pendingPosition = null; + }; const reset = () => { + cancelTrailing(); clearCursor(); lastPick = -Infinity; }; @@ -34,31 +50,56 @@ export function createLaunchHover({ if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') reset(); }; + const eligible = () => + enabled() && + !moving && + !documentRef?.hidden && + documentRef?.visibilityState !== 'hidden' && + pointerFree(); + const pick = (position, at) => { + lastPick = at; + const found = viewer.scene + .drillPick(position, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }; + const trailing = () => { + trailingTimer = null; + const position = pendingPosition; + pendingPosition = null; + if (!position) return; + // Eligibility may have changed while the check waited. + if (!eligible()) { + reset(); + return; + } + pick(position, now()); + }; const removeStart = viewer.camera?.moveStart?.addEventListener(start); const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); documentRef?.addEventListener?.('visibilitychange', visibility); return { move(movement) { - if ( - !enabled() || - moving || - documentRef?.hidden || - documentRef?.visibilityState === 'hidden' || - !pointerFree() - ) { + if (!eligible()) { reset(); return; } const at = now(); - if (at - lastPick < 120) return; - lastPick = at; - const found = viewer.scene - .drillPick(movement.endPosition, 12) - .some((picked) => entityLaunchId(picked?.id)); - if (found) { - viewer.scene.canvas.style.cursor = 'pointer'; - ownsCursor = true; - } else clearCursor(); + const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick); + if (wait > 0) { + // Cesium reuses the movement object, so keep a copy of the position. + pendingPosition = Cesium.Cartesian2.clone( + movement.endPosition, + pendingPosition ?? undefined, + ); + if (trailingTimer === null) trailingTimer = setTimer(trailing, wait); + return; + } + cancelTrailing(); + pick(movement.endPosition, at); }, reset, destroy() { From 0c20ed9975c60b217ff2fbedb9324e4644da6d41 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:33:05 +0100 Subject: [PATCH 14/25] fix(overpass): retry a joined caller's admission on its own quota A caller that joins a cold reservation no longer inherits another client's RATE_LIMITED result. Once the rejected reservation is released it retries: it either owns the next reservation and is admitted on its own quota, or joins an admitted fetch. Adds a two-client regression (exhausted owner, available joiner, exhausted joiner coalescing onto the admitted fetch). Also drops the duplicate memory publication for a disk hit in the handler; preflight already sets it, the handler only trims. Co-Authored-By: Claude Opus 5.5 --- server/providers/overpass.js | 6 ++-- server/providers/overpass/cache.js | 45 +++++++++++++++++------------- src/data/overpassProxy.test.mjs | 31 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/server/providers/overpass.js b/server/providers/overpass.js index a9726f22b..bf6004ac6 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -204,10 +204,8 @@ function overpassProxy({ routing = {} } = {}) { return; } } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } + // Preflight already published the disk hit to memory; only trim. + if (preflight.source === 'DISK') trimOverpassCache(); sendOverpassResponse( res, preflight.payload, diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 5e88c3f19..b1a09908a 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -79,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) { /** * Resolve every cache/coalescing layer before admitting a request to the local * upstream rate limiter. The injected limiter callback is invoked exactly once - * for a complete cache miss and never for memory, in-flight, or disk hits. + * for a complete cache miss and never for memory, in-flight, or disk hits. A + * caller that joined a reservation rejected by another client's limiter + * retries with its own limiter instead of inheriting that 429. * Exported so the admission ordering can be tested without a Vite server. * * @param {object} options @@ -103,25 +105,30 @@ async function resolveOverpassPreflight({ now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { - const cached = memoryCache.get(cacheKey); - if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) - return { source: 'HIT', payload: cached }; + for (;;) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; - // Reserve synchronously before disk I/O. The owner retains the reservation - // through upstream admission, fetch and cache publication. - const request = coalesceProxyRequest(inFlight, cacheKey, async () => { - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) { - memoryCache.set(cacheKey, disk); - return { source: 'DISK', payload: disk }; - } - if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; - return { source: 'UPSTREAM', payload: await loadUpstream() }; - }); - const result = await request.promise; - return request.shared && result.source !== 'RATE_LIMITED' - ? { ...result, source: 'INFLIGHT' } - : result; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through its own upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + if (!request.shared) return result; + // The owner's quota rejection belongs to the owner's client. The rejected + // reservation is released before joiners resume, so retry and let this + // caller own the next reservation (or join an admitted one) on its quota. + if (result.source === 'RATE_LIMITED') continue; + return { ...result, source: 'INFLIGHT' }; + } } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index 7f9b117ae..4a3b5780f 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -249,3 +249,34 @@ test('cold disk lookup is single-flight through upstream publication and quota a await resolveOverpassPreflight({ ...options, readDisk: async () => null }); assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); }); + +test('a joined client retries admission with its own quota after another client is rate limited', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, upstreams = 0; + const admissions = { a: 0, b: 0, c: 0 }; + const quota = { a: false, b: true, c: false }; + const payload = { status: 200, body: '{"elements":[]}' }; + const client = (name) => resolveOverpassPreflight({ + cacheKey: 'quota', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return reads === 1 ? disk : null; }, + allowUpstream: () => { admissions[name]++; return quota[name]; }, + loadUpstream: async () => { upstreams++; return payload; }, + }); + // A (quota exhausted) owns the cold reservation; B (quota available) and + // C (quota exhausted) join it while the disk lookup is pending. + const pending = [client('a'), client('b'), client('c')]; + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const [a, b, c] = await Promise.all(pending); + assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A"); + assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota'); + assert.equal(b.payload, payload); + assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch"); + assert.equal(c.payload, payload); + assert.deepEqual(admissions, { a: 1, b: 1, c: 0 }); + assert.equal(upstreams, 1); + assert.equal(inFlight.size, 0); +}); From b2fb4a261e38557715c956e72ad2a3f9203b5a7a Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Sat, 24 Oct 2026 03:21:42 +0530 Subject: [PATCH 15/25] feat(camera): add globe rotation (#1225) Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards. --- README.md | 4 + changelog.d/1225-globe-rotation.md | 2 + docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 4 +- scripts/voice-bench/phrases.mjs | 3 + server/providers/openai/instructions.js | 1 + server/providers/openai/toolDescriptions.js | 6 +- src/cameraGlobeRotation.test.mjs | 1322 +++++++++++++++++++ src/cameraHandoff.test.mjs | 2 +- src/cameraVerbs.js | 312 ++++- src/firstRunExperience.test.mjs | 18 +- src/radioMarkup.test.mjs | 29 + src/tooling/voiceBench.test.mjs | 13 + src/ui/applicationShell.js | 2 + src/ui/composition.js | 6 +- src/ui/locationNavigation.js | 1 + src/ui/navigationController.js | 3 + src/voice/actionSchemas.js | 2 +- src/voice/actionSchemas.test.mjs | 47 +- src/voice/geminiSession.js | 50 +- src/voice/geminiSession.test.mjs | 212 +++ src/voice/gevActions.js | 6 +- src/voice/globeResetConfirmation.test.mjs | 1131 ++++++++++++++++ src/voice/realtimeProtocol.js | 26 + src/voice/realtimeTurns.js | 239 +++- 25 files changed, 3407 insertions(+), 36 deletions(-) create mode 100644 changelog.d/1225-globe-rotation.md create mode 100644 src/cameraGlobeRotation.test.mjs create mode 100644 src/voice/globeResetConfirmation.test.mjs diff --git a/README.md b/README.md index 1b1fc46a0..80f052571 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md new file mode 100644 index 000000000..844966030 --- /dev/null +++ b/changelog.d/1225-globe-rotation.md @@ -0,0 +1,2 @@ +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b10f43f3b..994f9a623 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4982,7 +4982,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e313ade42..7b7171c68 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1086,5 +1086,7 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/dockerTrustedPeers.test.mjs" + "src/dockerTrustedPeers.test.mjs", + "src/cameraGlobeRotation.test.mjs", + "src/voice/globeResetConfirmation.test.mjs" ] diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..9d1bcc8ba 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([ // — camera verbs (tools #23/#24) — { phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] }, { phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] }, + { phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] }, + { phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] }, + { phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] }, { phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] }, { phrase: 'Fly the route we just drew', expect: 'fly_route' }, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..9735f300c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -16,6 +16,7 @@ function realtimeInstructions( 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.', 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 18de877db..b8121a201 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = { }, move_camera: { description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', $position: 1, parameters: { properties: { direction: { description: - 'Required except for orbit (defaults right/clockwise) and stop.', + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', $position: 2, }, mode: { description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', + 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, }, diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs new file mode 100644 index 000000000..db9cbd852 --- /dev/null +++ b/src/cameraGlobeRotation.test.mjs @@ -0,0 +1,1322 @@ +import { GLOBE_VIEW } from './locations.js'; +import { projectEarthDiscToViewport } from './celestialRing.js'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + activeCameraMotionId, + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + interruptGlobeRotation, + interruptCameraMotionIfActive, + moveCamera, +} from './cameraVerbs.js'; +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; + +const radians = Cesium.Math.toRadians; +const clone = (value) => Cesium.Cartesian3.clone(value); +const longitude = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; +const wrappedAngle = Cesium.Math.negativePiToPi; +const hasRenderHold = () => + getRenderGovernorDiagnostics().holds.includes('camera-verb'); + +function near(actual, expected, tolerance = 1e-10, message = '') { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function pose(camera) { + return { + position: clone(camera.positionWC), + direction: clone(camera.directionWC), + up: clone(camera.upWC), + }; +} + +function samePose(camera, expected) { + const actual = pose(camera); + for (const key of ['position', 'direction', 'up']) { + near( + Cesium.Cartesian3.distance(actual[key], expected[key]), + 0, + key === 'position' ? 1e-6 : 1e-10, + key, + ); + } +} + +function orthonormalBasis(camera) { + const { directionWC: direction, upWC: up, rightWC: right } = camera; + for (const vector of [direction, up, right]) { + near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis'); + } + near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12); + near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12); + near(Cesium.Cartesian3.dot(up, right), 0, 1e-12); + near( + Cesium.Cartesian3.distance( + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + right, + ), + 0, + 1e-12, + 'right-handed camera basis', + ); +} + +/** Use Cesium itself as an independent oracle for the swept camera path. */ +function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) { + const heights = []; + for (let i = 0; i <= 64; i += 1) { + const probe = Cesium.Camera.clone(camera); + probe.rotate(axis, (angle * i) / 64); + heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height); + } + return heights; +} + +/** Camera-space screen coordinates, before FOV/aspect scaling. */ +function projection(camera, target = Cesium.Cartesian3.ZERO) { + const offset = Cesium.Cartesian3.subtract( + target, + camera.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(offset, camera.directionWC); + return { + x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth, + y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth, + depth, + }; +} + +/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +function harness( + t, + { + latitude = 15, + longitudeDeg = 0, + height = GLOBE_VIEW.heightM, + heading = 0, + pitch = -90, + roll = 0, + ellipsoid = Cesium.Ellipsoid.WGS84, + viewportWidth = 1280, + viewportHeight = 800, + } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: viewportWidth, + clientHeight: viewportHeight, + width: viewportWidth, + height: viewportHeight, + }); + const scene = { + canvas, + drawingBufferWidth: viewportWidth, + drawingBufferHeight: viewportHeight, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + height, + ellipsoid, + ), + orientation: { + heading: radians(heading), + pitch: radians(pitch), + roll: radians(roll), + }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + const target = Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + 0, + ellipsoid, + ); + initCameraVerbs(viewer, () => target); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 0.1) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finishOnce = () => { + for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); + }; + return { viewer, camera, canvas, target, tick, finishOnce }; +} + +for (const [name, sceneMode] of [ + ['2D', Cesium.SceneMode.SCENE2D], + ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], + ['morphing', Cesium.SceneMode.MORPHING], +]) { + test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { + const { viewer, camera } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) + .ok, + true, + ); + const priorId = activeCameraMotionId(); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + viewer.scene.mode = sceneMode; + let handoffs = 0; + let cancellations = 0; + camera.cancelFlight = () => { + cancellations += 1; + }; + for (const mode of ['once', 'continuous']) { + const result = moveCamera( + { motion: 'rotate_globe', direction: 'left', mode }, + () => { + handoffs += 1; + return { ok: true }; + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /3D/); + } + assert.equal(handoffs, 0); + assert.equal(cancellations, 0); + assert.equal(activeCameraMotionId(), priorId); + assert.equal(hasRenderHold(), true); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); + }); + + test(`active globe rotation yields to ${name} without changing the camera`, (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + viewer.scene.mode = sceneMode; + const before = pose(camera); + let rotations = 0; + camera.rotate = () => { + rotations += 1; + }; + tick(); + assert.equal(rotations, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const before = pose(camera); + const entity = { id: 'cockpit-aircraft', position: {} }; + viewer.trackedEntity = entity; + viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal( + getActiveCameraMotion(), + null, + 'tracking acquisition must synchronously yield', + ); + viewer.trackedEntity = undefined; + viewer.trackedEntityChanged.raiseEvent(undefined); + tick(); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { + const { viewer } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok, + true, + ); + const yawId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + assert.equal(activeCameraMotionId(), yawId); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const globeId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent(undefined); + assert.equal(activeCameraMotionId(), globeId); +}); + +test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { + const first = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.viewer); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + const next = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); + assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const id = activeCameraMotionId(); + first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + assert.equal(activeCameraMotionId(), id); + next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +for (const latitude of [0, 30, -60, 89.5]) { + test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { + const { camera, tick, finishOnce } = harness(t, { latitude }); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const direction = clone(camera.directionWC); + const started = moveCamera({ motion: 'rotate_globe', mode: 'once' }); + assert.equal(started.ok, true); + assert.equal(started.direction, 'right'); + assert.equal(started.mode, 'once'); + for (let i = 0; i < 5; i += 1) { + tick(); + const current = projection(camera); + near(current.x, before.x); + near(current.y, before.y); + near(current.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + } + finishOnce(); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, direction), + radians(15), + ); + assert.equal(hasRenderHold(), false); + }); +} + +for (const [direction, sign] of [ + ['left', -1], + ['right', 1], +]) { + test(`globe ${direction} means visible landmark motion ${direction}`, (t) => { + const { camera, target, finishOnce } = harness(t, { latitude: 0 }); + const originalLongitude = longitude(camera); + near(projection(camera, target).x, 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(sign * projection(camera, target).x > 0.05); + near( + wrappedAngle(longitude(camera) - originalLongitude), + -sign * radians(15), + ); + }); +} + +for (const view of [ + { label: 'north-up', heading: 0 }, + { label: 'east-up', heading: 90 }, + { label: 'south-up', heading: 180 }, + { label: 'tilted east-up', heading: 90, pitch: -86 }, + { label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 }, + { label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 }, +]) { + for (const [direction, sign] of [ + ['left', -1], + ['right', 1], + ]) { + test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => { + const { camera, tick, finishOnce } = harness(t, view); + const ray = new Cesium.Ray( + clone(camera.positionWC), + clone(camera.directionWC), + ); + const intersection = Cesium.IntersectionTests.rayEllipsoid( + ray, + Cesium.Ellipsoid.WGS84, + ); + assert.ok(intersection, 'the original screen center must see the globe'); + const landmark = Cesium.Ray.getPoint( + ray, + intersection.start, + new Cesium.Cartesian3(), + ); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const originalLandmark = projection(camera, landmark); + near(originalLandmark.x, 0); + near(originalLandmark.y, 0); + // Exercise both bounded and continuous application of the same direction. + const mode = direction === 'left' ? 'once' : 'continuous'; + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode }).ok, + true, + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(0.25); + } + const after = projection(camera); + const movedLandmark = projection(camera, landmark); + assert.ok( + sign * movedLandmark.x > 0.001, + 'landmark must move in the requested screen direction', + ); + near(movedLandmark.y, originalLandmark.y); + near(after.x, before.x); + near(after.y, before.y); + near(after.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + }); + } +} + +test('equal left and right globe rotations restore the complete camera pose', (t) => { + const { camera, finishOnce } = harness(t, { + latitude: 42, + longitudeDeg: 170, + height: GLOBE_VIEW.heightM, + }); + const before = pose(camera); + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }); + finishOnce(); + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }); + finishOnce(); + samePose(camera, before); +}); + +test('globe rotation converts an inherited target frame without zooming or recentering', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000), + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + false, + ); + const before = pose(camera); + const screen = projection(camera); + const range = Cesium.Cartesian3.magnitude(camera.positionWC); + moveCamera({ + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', + }); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); + samePose(camera, before); + tick(); + tick(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); +}); + +for (const [speed, degreesPerSecond] of [ + ['slow', 4], + ['normal', 10], + ['fast', 20], +]) { + test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { + const { camera, tick } = harness(t); + const result = moveCamera({ + motion: 'rotate_globe', + mode: 'continuous', + speed, + }); + assert.equal(result.ok, true); + assert.equal(hasRenderHold(), true); + tick(); // First tick initializes the motion's wall-clock anchor. + const before = clone(camera.directionWC); + tick(0.1); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before), + radians(degreesPerSecond * 0.1), + ); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + const stopped = moveCamera({ motion: 'stop' }); + assert.equal(stopped.stopped, true); + assert.equal(hasRenderHold(), false); + const after = pose(camera); + tick(); + samePose(camera, after); + assert.equal(moveCamera({ motion: 'stop' }).stopped, false); + }); +} + +for (const event of ['pointerdown', 'touchstart', 'wheel']) { + test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { + const { camera, canvas, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + tick(); + const before = pose(camera); + canvas.dispatchEvent(new Event(event)); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + samePose(camera, before); + }); +} + +test('new navigation can cancel globe rotation before setting a new destination', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000), + }); + const destination = pose(camera); + tick(); + samePose(camera, destination); + assert.equal(hasRenderHold(), false); +}); + +test('replacement motion rejects a stale globe owner cancellation', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + const globeId = activeCameraMotionId(); + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); + const yawId = activeCameraMotionId(); + assert.notEqual(yawId, globeId); + assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false); + assert.equal(getActiveCameraMotion()?.kind, 'rotate'); + const position = clone(camera.positionWC); + tick(); + tick(); + near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); + assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); + assert.equal(hasRenderHold(), false); +}); + +test('replacing the viewer retires globe motion and its old clock listener', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + tick(); + const before = pose(camera); + const nextViewer = { + ...viewer, + clock: { onTick: new Cesium.Event() }, + }; + initCameraVerbs(nextViewer); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + nextViewer.clock.onTick.raiseEvent(nextViewer.clock); + samePose(camera, before); +}); + +for (const direction of ['up', 'down', 'diagonal']) { + test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { + const { camera } = harness(t); + const before = pose(camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe', direction }, () => { + handoffs += 1; + }); + assert.equal(result.ok, false); + assert.match(result.error, /left or right/); + assert.equal(handoffs, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +for (const mode of ['once', 'continuous']) { + for (const acquisition of ['tracking', 'reference frame']) { + test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { + const { viewer, camera, target, tick } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); + tick(); + tick(); + assert.equal(hasRenderHold(), true); + const tracked = { id: 'new-tracked-aircraft' }; + if (acquisition === 'tracking') viewer.trackedEntity = tracked; + else + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000), + ); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + assert.equal( + viewer.trackedEntity, + acquisition === 'tracking' ? tracked : undefined, + ); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + tick(); + samePose(camera, before); + }); + } +} + +test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { + const { viewer, camera, target } = harness(t); + viewer.trackedEntity = { id: 'tracked-aircraft' }; + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.match(result.error, /following tracked-aircraft/); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +test('the existing Globe View preset remains unchanged', () => { + assert.deepEqual(GLOBE_VIEW, { + heightM: 18_000_000, + pitchDeg: -90, + durationS: 2.8, + }); +}); +for (const latitude of [0, 90, -90]) + for (const height of [12_500_000, 18_000_000, 30_000_000]) + test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => { + const { camera, finishOnce } = harness(t, { latitude, height }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }) + .ok, + true, + ); + finishOnce(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + }); +test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { + const { camera, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' }) + .ok, + true, + ); + for (let i = 0; i < 45; i++) tick(0.1); + assert.ok( + Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height < + GLOBE_VIEW.heightM - 1000, + ); + assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + moveCamera({ motion: 'stop' }); + assert.equal(hasRenderHold(), false); +}); +test('close-up refusal preserves an inherited target frame', (t) => { + const { camera, target } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000)); + const before = pose(camera), + transform = Cesium.Matrix4.clone(camera.transform); + let calls = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(calls, 0); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); +}); +test('navigation callback never releases tracking for globe rotation', (t) => { + const { viewer, camera } = harness(t); + const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + viewer.trackedEntity = undefined; + }).ok, + false, + ); + assert.equal(calls, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); +}); +test('direct globe request refuses Cockpit before navigation mutation', (t) => { + const { viewer, camera } = harness(t); + initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }).ok, + false, + ); + assert.equal(calls, 0); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { + const { camera, tick } = harness(t, { + latitude: 50, + heading: 75, + pitch: -86, + roll: 35, + }); + const before = pose(camera); + const result = moveCamera( + { motion: 'rotate_globe', mode: 'continuous' }, + (start) => { + camera.setView({ + orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 }, + }); + return start(); + }, + ); + assert.equal(result.ok, true); + samePose(camera, before); + const screen = projection(camera); + tick(); + tick(); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); + orthonormalBasis(camera); +}); + +test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => { + const { camera } = harness(t); + let cancellations = 0; + const original = camera.cancelFlight; + camera.cancelFlight = function () { + cancellations += 1; + return original.call(this); + }; + const before = pose(camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + assert.equal(cancellations, 1); + samePose(camera, before); +}); + +test('legacy rotate remains local yaw at an unchanged camera position', (t) => { + const { camera, finishOnce } = harness(t, { latitude: 0 }); + const before = pose(camera); + moveCamera({ motion: 'rotate', direction: 'right' }); + finishOnce(); + near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction), + radians(15), + ); + assert.ok( + Math.abs(projection(camera).x) > 0.2, + 'local yaw intentionally moves Earth out of the center', + ); +}); + +test('legacy orbit still revolves around the selected ground target', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000)); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const range = Cesium.Cartesian3.distance(camera.positionWC, target); + moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' }); + tick(); + tick(); + assert.equal(getActiveCameraMotion()?.kind, 'orbit'); + near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6); + near(projection(camera, target).x, 0); + near(projection(camera, target).y, 0); + interruptCameraMotion('test-stop'); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); +}); + +// Actual UI/tool authority routes, not a direct call to the interrupt helper. +test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { + const { NavigationController } = await import('./ui/navigationController.js'); + const { viewer, camera, tick } = harness(t); + const navigation = new NavigationController({ + viewer, + tracking: { + flightsLayer: {}, + militaryFlightsLayer: {}, + satellitesLayer: {}, + }, + interruptCameraMotion(reason) { + assert.equal( + getActiveCameraMotion(), + null, + 'the intent stamp must retire rotation before release', + ); + return interruptCameraMotion(reason); + }, + interruptGlobeRotation, + isCockpitActive: () => false, + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + navigation._runExplicitNavigation('camera', () => { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + }); + const after = pose(camera); + tick(); + samePose(camera, after); +}); +test('orbit-toggle controller stops globe motion before toggling', async (t) => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const { target } = harness(t); + let toggled = false; + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + LocationNavigation.prototype._toggleOrbit.call({ + services: { interruptGlobeRotation }, + _currentTarget: target, + _currentPoi: {}, + orbitController: { + toggle() { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + toggled = true; + return true; + }, + }, + _orbitIndicator: { classList: { toggle() {} } }, + }); + assert.equal(toggled, true); +}); +for (const owner of ['tracking', 'cockpit', 'close-up']) + test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { + const { createGevActionRunner } = await import('./voice/gevActions.js'); + const { runExplicitNavigation } = await import('./navigationPolicy.js'); + const { viewer, camera } = harness(t, { + height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, + }); + const before = pose(camera); + if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' }; + const tracked = viewer.trackedEntity; + let releases = 0, + stamps = 0; + const styleManager = { + getCockpitState: () => ({ active: owner === 'cockpit' }), + runImmediateNavigation(noun, navigate) { + return runExplicitNavigation({ + cockpitActive: owner === 'cockpit', + noun, + stamp() { + stamps++; + }, + release() { + releases++; + viewer.trackedEntity = undefined; + }, + navigate, + }); + }, + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { + layers: new Map(), + isEnabled: () => false, + getAll: () => [], + }, + }); + const result = await runner('move_camera', { + motion: 'rotate_globe', + direction: 'left', + }); + assert.equal(result.ok, false); + assert.equal(releases, 0); + assert.equal(stamps, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); + if (owner === 'close-up') { + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + } + }); + +for (const [label, ellipsoid] of [ + ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], + ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], + ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], +]) + test(`globe rotation refuses ${label} before handoff`, (t) => { + const { viewer, camera } = harness(t, { height: 100_000_000 }); + const before = pose(camera); + viewer.scene.globe.ellipsoid = ellipsoid; + let handedOff = false; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handedOff = true; + }); + assert.equal(result.ok, false); + assert.equal( + result.reason, + label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', + ); + assert.equal(handedOff, false); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); + +test('an external radius change retires globe rotation without another write', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + camera.moveForward(1000); + const changed = pose(camera); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, changed); +}); + +test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { + const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); + const { camera } = harness(t, { ellipsoid, latitude: 60 }); + const before = pose(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(camera, before); +}); + +test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { + const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok( + Cesium.Cartographic.fromCartesian(camera.positionWC).height < + GLOBE_VIEW.heightM - 1, + ); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.equal( + moveCamera({ + motion: 'rotate_globe', + direction: 'left', + speed: 'fast', + mode: 'continuous', + }).ok, + true, + ); + tick(); + tick(); + assert.equal(getActiveCameraMotion().speed, 'fast'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); +}); + +function projected(h) { + return projectEarthDiscToViewport( + h.viewer, + h.canvas.clientWidth, + h.canvas.clientHeight, + ); +} + +test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => { + const h = harness(t, { latitude: 0, height: 12_500_000 }); + const disc = projected(h); + const clearance = + disc.keyholeRadius - + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) - + disc.earthRadius; + assert.ok( + clearance > 0 && clearance < 24, + `near-edge inside fit: ${clearance}`, + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +test('keyhole overlap refuses without a camera handoff or pose change', (t) => { + const h = harness(t, { latitude: 0, height: 11_000_000 }); + const before = pose(h.camera); + const disc = projected(h); + assert.ok(disc.earthRadius > disc.keyholeRadius); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(handoffs, 0); + samePose(h.camera, before); +}); + +for (const [angle, fits] of [ + [5, true], + [25, false], +]) + test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => { + const h = harness(t, { height: 30_000_000 }); + h.camera.lookRight(radians(angle)); + const before = pose(h.camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits); + samePose(h.camera, before); + }); + +test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { + const h = harness(t, { + viewportWidth: 2000, + viewportHeight: 500, + latitude: 0, + }); + assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov); + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-reset-unavailable', + ); +}); + +for (const invalid of [ + 'orthographic', + 'behind', + 'zero-css-width', + 'offset-frustum', +]) + test(`unsupported ${invalid} fails without a reset offer`, (t) => { + const h = harness(t); + if (invalid === 'orthographic') + h.camera.frustum = new Cesium.OrthographicFrustum({ + width: 50_000_000, + aspectRatio: 1.6, + }); + if (invalid === 'behind') h.camera.lookRight(Math.PI); + if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; + if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('a new request rechecks close-up framing rather than using previous eligibility', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.dispatchEvent(new Event('wheel')); + h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) }); + const before = pose(h.camera); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-view-required', + ); + samePose(h.camera, before); + assert.equal(getActiveCameraMotion(), null); +}); + +// Exact WGS84 equatorial tangent points expose the shared disc approximation. +// Keep the concrete counterexample public; an overlay approximation is not admission proof. +test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000 }); + h.camera.lookRight(radians(6)); + const disc = projected(h); + assert.ok( + disc.earthRadius + + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) < + disc.keyholeRadius, + 'the shared approximation reports a fit', + ); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const x = (radius * radius) / h.camera.positionWC.x; + const y = Math.sqrt(radius * radius - x * x); + const limbRadii = [-y, y].map((value) => { + const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + h.viewer.scene, + new Cesium.Cartesian3(x, value, 0), + ); + return Math.hypot( + pixel.x - disc.keyholeCenterX, + pixel.y - disc.keyholeCenterY, + ); + }); + assert.ok( + Math.max(...limbRadii) > disc.keyholeRadius, + 'an actual ellipsoid tangent point overlaps the keyhole', + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false); +}); + +for (const roll of [0, 35]) + test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000, roll }); + h.camera.lookRight(radians(6)); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + }); + +for (const latitude of [0, 90]) + for (const pitch of [0, 15]) + test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => { + const h = harness(t, { latitude, pitch, height: 5000 }); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + samePose(h.camera, before); + }); + +for (const [viewportWidth, viewportHeight, fits] of [ + [390, 844, true], + [844, 390, true], + [800, 1200, true], + [1280, 800, true], + [3440, 1440, false], +]) + test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => { + const h = harness(t, { + viewportWidth, + viewportHeight, + height: GLOBE_VIEW.heightM, + latitude: 0, + }); + const disc = projected(h); + assert.equal( + disc.earthRadius < disc.keyholeRadius, + fits, + 'reset preset keyhole fit', + ); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, fits); + if (!fits) assert.equal(result.reason, 'globe-reset-unavailable'); + samePose(h.camera, before); + }); + +test('keyhole overlap after resize stops rotation before another pose write', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.clientWidth = 2000; + h.canvas.clientHeight = 500; + h.camera.frustum.aspectRatio = 4; + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + const before = pose(h.camera); + h.tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(h.camera, before); +}); + +for (const clipped of ['near', 'far', 'entirely-beyond-far']) + test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; + if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; + if (clipped === 'entirely-beyond-far') + h.camera.frustum.far = distance - 2 * radius; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); + }); + +test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius - 1; + h.camera.frustum.far = distance + radius + 1; + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +for (const [near, far] of [ + [0, 1e9], + [-1, 1e9], + [NaN, 1e9], + [1, Infinity], + [10, 10], + [10, 9], +]) + test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { + const h = harness(t); + h.camera.frustum.near = near; + h.camera.frustum.far = far; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('bare globe rotation defaults right and continues beyond a nudge', (t) => { + const { camera, tick } = harness(t, { latitude: 0 }); + const before = projection(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'right'); + assert.equal(result.mode, 'continuous'); + assert.equal(result.speed, 'normal'); + for (let i = 0; i < 30; i++) tick(0.1); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + near(projection(camera).x, before.x); + near(projection(camera).y, before.y); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); + assert.equal(hasRenderHold(), false); +}); +for (const [args, direction, mode, speed] of [ + [{ mode: 'once' }, 'right', 'once', 'normal'], + [{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'], + [{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'], + [ + { direction: 'right', mode: 'continuous', speed: 'fast' }, + 'right', + 'continuous', + 'fast', + ], +]) + test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => { + const { finishOnce, tick } = harness(t); + const result = moveCamera({ motion: 'rotate_globe', ...args }); + assert.deepEqual( + [result.ok, result.direction, result.mode, result.speed], + [true, direction, mode, speed], + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + } + }); +for (const motion of ['rotate', 'pan', 'tilt', 'orbit']) + test(`legacy ${motion} retains its once default`, (t) => { + harness(t); + const result = moveCamera({ + motion, + direction: motion === 'tilt' ? 'up' : 'left', + }); + assert.equal(result.ok, true); + assert.equal(result.mode, 'once'); + }); +for (const mode of [null, '', 'invalid', 'CONTINUOUS']) + test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => { + const { finishOnce } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once'); + finishOnce(); + }); + +test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => { + const h = harness(t, { latitude: 0 }); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + h.canvas.clientWidth = 390; + h.canvas.clientHeight = 844; + h.camera.frustum.aspectRatio = 390 / 844; + const disc = projected(h); + assert.ok(disc.earthRadius < disc.keyholeRadius); + assert.ok(disc.earthRadius > h.canvas.clientWidth / 2); + const before = pose(h.camera); + h.tick(); + h.tick(); + assert.notDeepEqual(pose(h.camera).position, before.position); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + assert.equal(hasRenderHold(), true); +}); + +test('an invalid camera request leaves the active globe rotation untouched', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + const before = pose(h.camera); + const active = getActiveCameraMotion(); + assert.equal(moveCamera({ motion: 'pan' }).ok, false); + assert.deepEqual(getActiveCameraMotion(), active); + samePose(h.camera, before); + assert.equal(hasRenderHold(), true); +}); + +test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { + const h = harness(t, { + viewportWidth: 3440, + viewportHeight: 1440, + height: 5000, + }); + const before = pose(h.camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-reset-unavailable'); + assert.equal(handoffs, 0); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index d91b9d8a4..1fe30ff06 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 278a86206..231d9357d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -4,7 +4,7 @@ * * One motion at a time, driven per clock tick. `once` = bounded eased nudge; * `continuous` runs until move_camera{stop}, ANY manual camera input on the - * canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool + * canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool * starts (the shared UI navigation policy releases this owner before the new * destination mutates the camera). fly_route is a * continuous motion in the same slot: a cinematic dolly along an existing @@ -13,14 +13,24 @@ * Orbit circles the current screen-center ground target holding range and * pitch while advancing heading (per-frame camera.lookAt; the lookAt * transform is cleared on every stop so manual control returns cleanly). + * Globe rotation turns the world-frame camera about its screen-up axis through + * Earth's center, preserving range and framing. Left/right name the visible + * globe motion, opposite to the camera's travel around Earth. */ import * as Cesium from 'cesium'; +import { GLOBE_VIEW } from './locations.js'; +import { + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, +} from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 }; @@ -952,6 +962,7 @@ let _viewer = null; let _getTarget = null; let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; +let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1162,7 +1173,11 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if (m.kind === 'tilt' || m.kind === 'rotate') { + } else if ( + m.kind === 'tilt' || + m.kind === 'rotate' || + m.kind === 'rotate_globe' + ) { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' @@ -1172,7 +1187,38 @@ function onTick() { (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; - if (m.kind === 'tilt') { + if (m.kind === 'rotate_globe') { + // Tracking can acquire the camera without going through navigation. + // Yield without resetting its pose/frame: the world-space arc proof + // only describes Cesium's rotation while the frame is identity. + if ( + _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || + _viewer.trackedEntity || + _isCockpitActive?.() || + !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ) { + interruptCameraMotion('globe-camera-owner-changed'); + return; + } + // Cesium rotates both position and basis about the reference-frame + // origin. The captured screen-up axis keeps left/right horizontal even + // in a tilted or rolled view; a positive angle moves the globe right. + const angle = m.direction === 'left' ? -stepRad : stepRad; + if (globeRotationFitsKeyhole() !== true) { + interruptCameraMotion('globe-framing-changed'); + return; + } + if ( + !globeRotationHasClearance(cam.positionWC) || + Math.abs( + Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius, + ) > 0.01 + ) { + interruptCameraMotion('globe-surface-limit'); + return; + } + cam.rotate(m.rotationAxis, angle); + } else if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); @@ -1206,6 +1252,133 @@ function onTick() { } } +/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ +function globeRotationHasClearance(position) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const { minimumRadius, maximumRadius } = ellipsoid; + const radius = Cesium.Cartesian3.magnitude(position); + return ( + Number.isFinite(minimumRadius) && + minimumRadius > 0 && + Number.isFinite(maximumRadius) && + maximumRadius >= minimumRadius && + Number.isFinite(radius) && + radius > maximumRadius * (1 + 1e-12) + ); +} + +/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */ +function globeRotationFitsKeyhole(camera = _viewer.camera) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii)) + return null; + const canvas = _viewer.scene.canvas; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const frustum = camera.frustum; + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + !(frustum instanceof Cesium.PerspectiveFrustum) || + !Number.isFinite(frustum.near) || + frustum.near <= 0 || + !Number.isFinite(frustum.far) || + frustum.far <= frustum.near || + frustum.xOffset !== 0 || + frustum.yOffset !== 0 + ) + return null; + try { + const cam = camera; + const distance = Cesium.Cartesian3.magnitude(cam.positionWC); + const sphereRadius = ellipsoid.maximumRadius; + if (!Number.isFinite(distance) || distance <= 0) return null; + if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too. + const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy); + if (!radiusPx) return null; + const keyhole = getKeyholeGeometry(width, height); + // The known nadir reset probe uses the same exact cone below; projecting + // through SceneTransforms would read the live scene.camera instead. + if ( + camera === _viewer.camera && + !projectEarthDiscToViewport(_viewer, width, height) + ) { + // A near-surface horizon view cannot fit even when pointed at Earth; + // an orbital camera looking away is unsupported, not a close-up offer. + return radiusPx > Math.min(keyhole.radius, width / 2, height / 2) + ? false + : null; + } + // The shared disc is an overlay approximation off-axis. Bound the sphere + // against the existing keyhole cone. Canvas-edge clipping does not change + // framing during rigid globe rotation and is not a clearance condition, + // without changing the UI projection or defining another keyhole/mask. + const toCenter = Cesium.Cartesian3.negate( + cam.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC); + const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC)); + const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC)); + const focalY = height / (2 * Math.tan(frustum.fovy / 2)); + const focalX = + width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio); + if ( + ![depth, right, up, focalX, focalY].every(Number.isFinite) || + focalX <= 0 || + focalY <= 0 + ) + return null; + const halfAngle = Math.asin(sphereRadius / distance); + const centerAngle = Math.atan2(Math.hypot(right, up), depth); + const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY)); + // No overlay visibility hysteresis: a fully contained smaller globe needs no reset. + const framed = centerAngle + halfAngle <= keyholeAngle; + if (!framed) return false; + // Projection alone does not prove that a tiny globe is within depth planes. + if ( + depth - sphereRadius < frustum.near || + depth + sphereRadius > frustum.far + ) + return null; + return true; + } catch { + return null; + } +} + +/** Check the unchanged reset destination without moving the live camera. */ +function globeResetFitsKeyhole() { + try { + const camera = Cesium.Camera.clone(_viewer.camera); + const { longitude, latitude } = _viewer.camera.positionCartographic; + camera.setView({ + destination: Cesium.Cartesian3.fromRadians( + longitude, + latitude, + GLOBE_VIEW.heightM, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), + roll: 0, + }, + endTransform: Cesium.Matrix4.IDENTITY, + }); + return globeRotationFitsKeyhole(camera) === true; + } catch { + return false; + } +} + +/** Release only globe rotation when a new camera intent takes ownership. */ +export function interruptGlobeRotation(reason = 'camera-intent') { + if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason); + return { wasActive: false, reason, leveled: false }; +} + /** * Wire the module to the viewer once (idempotent). The view-target getter is * optional so a non-voice caller (the Directions FLY chip) can arm route @@ -1218,24 +1391,42 @@ function onTick() { * call brings none, so a callback from the old viewer can never answer for the * new one. */ -export function initCameraVerbs(viewer, getViewTargetCartesian = null) { +export function initCameraVerbs( + viewer, + getViewTargetCartesian = null, + { isCockpitActive } = {}, +) { const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; + if (typeof isCockpitActive === 'function') + _isCockpitActive = isCockpitActive; return; } + // A globe motion belongs to the viewer whose world pose it captured. + if (_active?.kind === 'rotate_globe') + interruptCameraMotion('viewer-replaced'); _getTarget = nextTarget; + _isCockpitActive = + typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); + if (_trackingRemover) _trackingRemover(); + // Cockpit can acquire then clear tracking before the next clock tick. + // Yield on acquisition itself without changing legacy camera motions. + _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { + if (entity && _active?.kind === 'rotate_globe') + interruptCameraMotion('globe-camera-owner-changed'); + }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of ['pointerdown', 'wheel']) { + for (const evt of ['pointerdown', 'touchstart', 'wheel']) { const h = () => interruptCameraMotion('manual-input'); canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); @@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) { const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const mode = args.mode === 'continuous' ? 'continuous' : 'once'; + const mode = + args.mode === 'continuous' || + (motion === 'rotate_globe' && args.mode === undefined) + ? 'continuous' + : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); const stop = () => { @@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { return { ok: false, action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } if ( motion !== 'orbit' && + motion !== 'rotate_globe' && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate needs left or right.', }; } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) { } } if ( - motion === 'orbit' && - _viewer?.trackedEntity && - typeof runNavigation !== 'function' + (motion === 'rotate_globe' || + (motion === 'orbit' && typeof runNavigation !== 'function')) && + _viewer?.trackedEntity ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal @@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) { return { ok: false, action: 'move_camera', - error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`, }; } + let globePose; + if (motion === 'rotate_globe') { + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) { + return { + ok: false, + action: 'move_camera', + error: 'Globe rotation requires a 3D view.', + }; + } + const cam = _viewer.camera; + globePose = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()), + right: Cesium.Cartesian3.clone(cam.rightWC), + }; + if (_isCockpitActive?.()) + return { + ok: false, + action: 'move_camera', + reason: 'camera-owned', + error: 'Exit Cockpit before rotating the globe.', + }; + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!( + Number.isFinite(ellipsoid.minimumRadius) && + ellipsoid.minimumRadius > 0 && + Number.isFinite(ellipsoid.maximumRadius) && + ellipsoid.maximumRadius >= ellipsoid.minimumRadius && + Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position)) + )) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: + 'Globe rotation requires a finite supported ellipsoid and a clear globe view.', + }; + const fits = globeRotationFitsKeyhole(); + if (fits === null) + return { + ok: false, + action: 'move_camera', + reason: 'unsupported-view', + error: 'Globe rotation requires a supported perspective view.', + }; + if (!fits) { + const canReset = globeResetFitsKeyhole(); + return { + ok: false, + action: 'move_camera', + reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable', + error: canReset + ? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’' + : 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.', + }; + } + if (!globeRotationHasClearance(globePose.position)) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: 'Globe rotation requires a clear ellipsoid bounding sphere.', + }; + } const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'rotate_globe') { + // Convert an inherited ground-target frame without changing the world + // pose. Rotation must pivot about Earth, never the previous local target. + const cam = _viewer.camera; + cam.cancelFlight(); + cam.lookAtTransform(Cesium.Matrix4.IDENTITY); + // Handoff may level a route's bank. Keep the caller's exact world pose. + // Copy the basis directly: setView's heading/pitch conversion is singular + // at nadir and can change the captured orientation near the poles. + Cesium.Cartesian3.clone(globePose.position, cam.position); + Cesium.Cartesian3.clone(globePose.direction, cam.direction); + Cesium.Cartesian3.clone(globePose.up, cam.up); + Cesium.Cartesian3.clone(globePose.right, cam.right); + state.rotationAxis = globePose.up; + state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position); + } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 40d42befc..a02c4391f 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping ]); // Cyber deliberately adds one layout; first-run missions still change no tools. hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties.motion; + assert.deepEqual(cameraMotion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt @@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // Globe rotation adds one camera motion and its descriptions; Globe View + // framing/default guidance updates only move_camera and mode descriptions. + // Radio markup and the complete production-request comparison pin locality. + // Restore the base camera/Globe View wording with the globe-rotation append. + assert.equal(block.length, 30878, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..14ea0f3e0 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => { const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + // Globe rotation adds one motion and clarifies the two affected descriptions. + // Check those edits explicitly, then keep the legacy pin for every other field. + const camera = unchanged.find((tool) => tool.name === 'move_camera'); + assert.deepEqual(camera.parameters.properties.motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + assert.equal( + camera.description, + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', + ); + assert.equal( + camera.parameters.properties.direction.description, + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + ); + assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); + camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; + camera.parameters.properties.motion.enum = + camera.parameters.properties.motion.enum.filter( + (motion) => motion !== 'rotate_globe', + ); + camera.description = + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").'; + camera.parameters.properties.direction.description = + 'Required except for orbit (defaults right/clockwise) and stop.'; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) diff --git a/src/tooling/voiceBench.test.mjs b/src/tooling/voiceBench.test.mjs index 84c31f3dc..1cc69c8da 100644 --- a/src/tooling/voiceBench.test.mjs +++ b/src/tooling/voiceBench.test.mjs @@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]); assert.equal(inMeters.fullCallOk, true); assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true); + const globe = byPhrase('Keep spinning the Earth slowly to the left'); + const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' }; + for (const mode of [undefined, 'continuous', 'once', null]) { + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous'); + } + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false); + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false); + const right = byPhrase('Rotate the globe to the right'); + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true); + for (const direction of ['right', 'left', null]) { + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right'); + } + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false); }); test('gradeDialogue scores turn 2 in the context of turn 1', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index de37ab036..7c866bc36 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -147,6 +147,7 @@ export class StyleManager extends ShellFacade { }, searchInput: this._locationSearch, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, isCockpitActive: () => !!this.cockpitView?.active, clearLocation: () => this.clearSearchedLocation(), cancelShareSelection: () => this._shareRestoration.cancelSelection(), @@ -322,6 +323,7 @@ export class StyleManager extends ShellFacade { GLOBE_VIEW: services.GLOBE_VIEW, flyToGlobeView: services.flyToGlobeView, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, satellitesLayer: services.satellitesLayer, diff --git a/src/ui/composition.js b/src/ui/composition.js index 797157567..c56c6f37c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -9,7 +9,10 @@ import { flyToPOI, searchAndFlyTo, } from '../locations.js'; -import { interruptCameraMotion } from '../cameraVerbs.js'; +import { + interruptCameraMotion, + interruptGlobeRotation, +} from '../cameraVerbs.js'; import { IntelHUD } from '../hud.js'; import { ShareLinkManager } from '../sharelink.js'; import { OrbitController } from '../orbit.js'; @@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell { flyToPOI, searchAndFlyTo, interruptCameraMotion, + interruptGlobeRotation, IntelHUD, ShareLinkManager, OrbitController, diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 07ce43d8d..af8fb95b8 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -283,6 +283,7 @@ export class LocationNavigation { return; } + this.services.interruptGlobeRotation?.('orbit-intent'); const isActive = this.orbitController.toggle(this._currentTarget, { radius: this._currentPoi?.alt || 500, pitch: this._currentPoi?.pitch || -30, diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f94eb46fe..437b643f3 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -14,6 +14,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation = () => {}, isCockpitActive, clearLocation, cancelShareSelection, @@ -27,6 +28,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation, isCockpitActive, clearLocation, cancelShareSelection, @@ -46,6 +48,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { + this.interruptGlobeRotation('navigation-intent'); cancelCameraArrival(this.viewer); this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index d0810bf71..3f464cad1 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -729,7 +729,7 @@ const schemas = [ properties: { motion: { type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'], }, direction: { type: 'string', diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..d65d7d8db 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -1,3 +1,4 @@ +import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // Globe rotation adds one motion; visual keyhole fit and explicit + // reset guidance/defaults update only move_camera and mode descriptions. + // Restore base descriptions; append only globe-rotation defaults and fit guidance. + '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', ); }); @@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; delete property('analyst_query').layers.items.enum; + // Earth-centered rotation is additive; local camera yaw keeps its contract. + const motion = property('move_camera').motion; + assert.deepEqual(motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), @@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera ); }); +test('globe rotation is an additive camera motion with the existing control arguments', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) { + assert.deepEqual( + validateArgs(schema, { + motion, + direction: 'right', + speed: 'slow', + mode: 'continuous', + }), + [], + ); + } + assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0); +}); + test('track_entity accepts a referent alone but rejects an empty target', () => { const schema = GEV_ACTION_SCHEMAS.find( (tool) => tool.name === 'track_entity', @@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin [], ); }); + +test('globe reset guidance requires a separate explicit command after refusal', () => { + const paragraph = realtimeInstructions() + .split('\n') + .find((line) => line.includes('globe-view-required')); + assert.ok(paragraph); + assert.match(paragraph, /calls only move_camera motion rotate_globe/); + assert.match(paragraph, /explicit user Reset\/Show Globe View/); + assert.match(paragraph, /never from your own plan/); + assert.match(paragraph, /bare yes\/ok/); + assert.match(paragraph, /Do not ask a question/); +}); diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 04a573f76..041f1f643 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -21,6 +21,7 @@ import { shouldSendViewportImage, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, } from './realtimeProtocol.js'; const SOCKET_PATH = @@ -182,6 +183,8 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.inputGeneration = 0; this.turnAbort = new AbortController(); this.disposed = false; @@ -659,6 +662,8 @@ export class GeminiSessionController { return; if (!pushToTalk || activityCaptureId === metadata.captureId) return; this.captureOwner.admitted = true; + this.globeToolsBlocked = false; + this.globeResetFailed = false; if ( !this.interrupt('push-to-talk', { keepPlayback: !this.pttPlaybackSuppressed, @@ -1294,6 +1299,8 @@ export class GeminiSessionController { this.replyPending = false; if (!this.interrupt()) return; } else if (!this.retiredReplyPending && !this.interrupt()) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; // Local activity ownership is only for expiry liveness, not tools. // Assign after retiring observed output so its boundary cannot clear // a newer detected activity that has not received a reply yet. @@ -1573,6 +1580,7 @@ export class GeminiSessionController { .then(async () => { if (!this.owns(epoch) || turn !== this.turn) return; if (this.cancelledCalls.has(call.id)) { + if (call.name === 'zoom_to_globe') this.globeResetFailed = true; if (this.tools.get(call.id) === action) this.tools.delete(call.id); this.pendingTools--; if (!this.pendingTools) { @@ -1618,8 +1626,28 @@ export class GeminiSessionController { (authority !== 'playback' || handoffEpoch === this.radio.radioHandoffEpoch); let result; + let skippedGlobeRotation = false; + let blockedGlobeTool = false; try { if (!actionCurrent()) throw cancelled(); + // Live has no response-scoped tool_choice. Recheck here so queued siblings + // cannot reset after a rotation refusal. + if (this.globeToolsBlocked) { + blockedGlobeTool = true; + throw new Error( + 'Wait for fresh user input before calling another tool.', + ); + } + if ( + this.globeResetFailed && + call.name === 'move_camera' && + String(args.motion || '').toLowerCase() === 'rotate_globe' + ) { + skippedGlobeRotation = true; + throw new Error( + 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + ); + } if (stronger) reservation = this.radio.reserveRadioToolHandoff({ abortScope }); handoffEpoch = this.radio.radioHandoffEpoch; @@ -1689,7 +1717,12 @@ export class GeminiSessionController { } catch (error) { result = { ok: false, - tool: call.name, + ...(skippedGlobeRotation + ? { action: call.name, reason: 'globe-reset-failed', skipped: true } + : { + tool: call.name, + ...(blockedGlobeTool ? { skipped: true } : {}), + }), ...(radioVisibility ? { layerId: 'radio' } : {}), error: error?.name === 'AbortError' @@ -1716,6 +1749,11 @@ export class GeminiSessionController { this.noteRadioStrongerCommit(action, abortScope, turn); } if (!this.owns(epoch) || turn !== this.turn) return; + if ( + call.name === 'zoom_to_globe' && + (result?.ok !== true || result.cancelled) + ) + this.globeResetFailed = true; const playbackObsolete = authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch; if (radioControl || radioVisibility) { @@ -1744,6 +1782,8 @@ export class GeminiSessionController { this.stopAfterReply = true; // Native call cancellation suppresses its output, without canceling siblings. if (!this.cancelledCalls.has(call.id)) { + if (isGlobeViewRefusal(result) && !skippedGlobeRotation) + this.globeToolsBlocked = true; this.postToolOutput = false; this.modelComplete = false; this.replyPending = true; @@ -1751,7 +1791,9 @@ export class GeminiSessionController { const mapEvents = mapSnapshot.map((text) => JSON.parse(text)); const response = { ...(result ?? { ok: false, error: 'Command returned no result' }), - responseGuidance: responseInstructionForToolResult(result), + responseGuidance: blockedGlobeTool + ? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.' + : responseInstructionForToolResult(result), }; this.send( { @@ -1902,6 +1944,8 @@ export class GeminiSessionController { { onSent: () => { if (!owned() || this.typedIntent !== typedIntent) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = true; this.bindTextIntent(typedIntent); if (!owned() || this.typedIntent !== typedIntent) return; @@ -2455,6 +2499,8 @@ export class GeminiSessionController { this.cancelledCalls.clear(); this.pendingMapEvents = []; this.userTurnPending = false; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = false; this.unrequestedReply = false; this.automaticTurnOpen = false; diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index b2f380b37..9ce8d351a 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f assert.deepEqual(h.calls, []); h.c.stop(); }); + +for (const reply of ['typed', 'push-to-talk', 'open-mic']) { + test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => { + const names = []; + const h = harness({ + runAction: async (name) => { + names.push(name); + return name === 'move_camera' + ? { ok: false, action: 'move_camera', reason: 'globe-view-required' } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start({ pushToTalk: reply === 'push-to-talk' }); + establishToolInput(h, 'rotate the globe'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'rotate', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'queued-reset', name: 'zoom_to_globe', args: {} }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const blocked = ws.sent + .flatMap((entry) => entry.toolResponse?.functionResponses || []) + .find((call) => call.id === 'queued-reset'); + assert.equal(blocked.response.ok, false); + assert.match(blocked.response.error, /fresh user input/i); + assert.equal(blocked.response.skipped, true); + assert.match(blocked.response.responseGuidance, /did not run/i); + assert.doesNotMatch( + blocked.response.responseGuidance, + /confirm.*completed/i, + ); + requestTool(ws, 'repeated-rotation', 'move_camera', { + motion: 'rotate_globe', + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const repeated = toolResponses(ws).find( + (call) => call.id === 'repeated-rotation', + ).response; + assert.equal(repeated.ok, false); + assert.equal(repeated.skipped, true); + assert.match(repeated.responseGuidance, /did not run/i); + assert.doesNotMatch( + repeated.responseGuidance, + /confirm.*completed/i, + ); + + if (reply === 'typed') { + const stimuli = [ + () => {}, + () => ws.message({ serverContent: { interrupted: true } }), + () => + ws.message({ + serverContent: { + inputTranscription: { text: 'reset to globe view' }, + }, + }), + () => { + h.c.input.setMicrophoneEnabled(false); + h.c.input.setMicrophoneEnabled(true); + }, + ]; + for (const [i, stimulus] of stimuli.entries()) { + stimulus(); + requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + } + } + // Finish the refusal reply before admitting the actual next user input. + ws.message({ serverContent: { turnComplete: true } }); + if (reply === 'typed') + assert.equal(h.c.sendTextCommand('reset to globe view'), true); + else if (reply === 'push-to-talk') { + h.c.input.pushToTalkKeyHeld = true; + h.c.input.spaceKeyHeld = true; + h.c.input.setMicrophoneEnabled(true); + h.c.input.setMicrophoneEnabled(false); + } else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); + requestTool(ws, 'accepted-reset', 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera', 'zoom_to_globe']); + } finally { + h.c.stop(); + } + }); +} + +test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => { + const calls = []; + let resets = 0; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return name === 'zoom_to_globe' && resets++ === 0 + ? { ok: false, action: name, cancelled: true } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-failed', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-skipped', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, ['zoom_to_globe', 'stop']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-skipped', + ).response; + assert.equal(skipped.ok, false); + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + assert.equal( + toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok, + true, + ); + + ws.message({ serverContent: { turnComplete: true } }); + establishToolInput(h, 'reset to globe view and rotate'); + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-success', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-success', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'stop', + 'zoom_to_globe', + 'rotate_globe', + ]); + } finally { + h.c.stop(); + } +}); + +test('cancelling a queued globe reset also skips its dependent rotation', async () => { + const blocker = deferred(); + const calls = []; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return args.motion === 'pan' + ? blocker.promise + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'pan, reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'blocker', + name: 'move_camera', + args: { motion: 'pan', direction: 'left' }, + }, + { id: 'reset-cancelled', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-after-cancel', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await tick(); + ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } }); + blocker.resolve({ ok: true, action: 'move_camera' }); + await h.c.toolQueue; + assert.deepEqual(calls, ['pan']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-after-cancel', + ).response; + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + } finally { + blocker.resolve({ ok: true }); + h.c.stop(); + } +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 49e2ba20a..f7613aed1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -262,7 +263,9 @@ export function createGevActionRunner({ }), )); installViewTargetPrewarm(viewer); - initCameraVerbs(viewer, getViewTargetCartesian); + initCameraVerbs(viewer, getViewTargetCartesian, { + isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), + }); // Nested calls (select_nearest_aircraft composing layer + flight) use the // raw runner; only the outer call gets a voice envelope. const runGevAction = async function runGevAction( @@ -298,6 +301,7 @@ export function createGevActionRunner({ // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // a straight camera move would be snapped back by the per-frame lookAt. if (name === 'adjust_camera_zoom') { + interruptGlobeRotation('voice-zoom-intent'); const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs new file mode 100644 index 000000000..be2778e6d --- /dev/null +++ b/src/voice/globeResetConfirmation.test.mjs @@ -0,0 +1,1131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GevRealtimeController } from './realtimeController.js'; +import { + CALL_DEDUPE_MS, + responseInstructionForToolResult, +} from './realtimeProtocol.js'; + +const refusal = { + ok: false, + action: 'move_camera', + reason: 'globe-view-required', +}; + +function harness(result) { + const sent = []; + const calls = []; + const controller = new GevRealtimeController({ + ui: {}, + runner: async (name, args, context) => { + calls.push(name); + return typeof result === 'function' + ? result(name, args, context) + : result; + }, + }); + controller.dc = { readyState: 'open' }; + controller.sendRealtimeEvent = (event) => { + sent.push(event); + return true; + }; + controller.debugLog = () => {}; + controller.setStatus = () => {}; + controller.setVoiceSpeaker = () => {}; + controller.recordUsage = () => {}; + controller.captureViewportScreenshot = async () => null; + controller.cancelRadioHandoff = () => {}; + return { controller, sent, calls }; +} + +async function invoke( + controller, + { + responseId = 'rotation-response', + callId = 'rotation-call', + name = 'move_camera', + args = { motion: 'rotate_globe' }, + } = {}, +) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: `${callId}-item`, + call_id: callId, + name, + arguments: JSON.stringify(args), + }), + }); +} +/** A real user input boundary: the provider detected new speech. */ +async function speak(controller) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'input_audio_buffer.speech_started' }), + }); +} +function begin(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.created', + response: { id }, + }); +} +function complete(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.done', + response: { id, status: 'completed' }, + }); +} + +test('close-up rotation refusal instructs an explicit reset in a response with tools disabled', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(response?.tool_choice, 'none'); + assert.equal( + response.instructions, + responseInstructionForToolResult(refusal), + ); + assert.match(response.instructions, /do not ask a question/i); + assert.doesNotMatch(response.instructions, /confirm the completed/); +}); + +test('normal camera success retains the existing unrestricted followup', async () => { + const { controller, sent } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate', + }); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(typeof response?.instructions, 'string'); + assert.equal(Object.hasOwn(response, 'tool_choice'), false); +}); + +test('a newer typed request drops the old refusal followup and its tool restriction', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + controller.sendTextCommand('turn the camera right'); + complete(controller); + const responses = sent.filter((event) => event.type === 'response.create'); + assert.equal(responses.length, 1); + assert.equal(responses[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('refusal instructions do not interpolate untrusted failure text or alter other tool results', () => { + const instruction = responseInstructionForToolResult({ + ...refusal, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.doesNotMatch(instruction, /UNTRUSTED/); + const unavailable = responseInstructionForToolResult({ + ...refusal, + reason: 'globe-reset-unavailable', + }); + assert.doesNotMatch(unavailable, /Reset to Globe View and rotate the globe/); + assert.match(unavailable, /do not offer or perform a reset/i); + const completedReset = responseInstructionForToolResult({ + action: 'zoom_to_globe', + ok: true, + }); + assert.equal( + completedReset, + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); + for (const outcome of [ + { action: 'zoom_to_globe', ok: false }, + { action: 'zoom_to_globe', ok: true, cancelled: true }, + { tool: 'zoom_to_globe', ok: false }, + ]) { + const resetFailure = responseInstructionForToolResult({ + ...outcome, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.notEqual(resetFailure, completedReset); + assert.match(resetFailure, /did not finish/i); + assert.doesNotMatch(resetFailure, /UNTRUSTED/); + } + assert.equal( + responseInstructionForToolResult({ + ok: true, + action: 'get_current_view_state', + }), + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); +}); + +test('globe refusal blocks same-response tools and preserves the reset instruction, but not the next response', async () => { + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : { ok: true, action: name }, + ); + begin(controller); + await invoke(controller); + await invoke(controller, { + callId: 'reset-call', + name: 'zoom_to_globe', + args: {}, + }); + // The second delivery of the original call must not produce another output. + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'reset-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, false); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); + // A fresh explicit reset request is a separate user input. + await speak(controller); + begin(controller, 'fresh-response'); + await invoke(controller, { + responseId: 'fresh-response', + callId: 'fresh-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'fresh-response'); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('a reset arriving during pending globe admission cannot bypass the refusal', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller, { args: { motion: 'ROTATE_GLOBE' } }); + const reset = invoke(controller, { + callId: 'overlap-reset', + name: 'zoom_to_globe', + args: {}, + }); + finishRotation(refusal); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.equal(outputs.length, 2); + assert.equal( + JSON.parse( + outputs.find((event) => event.item.call_id === 'overlap-reset').item + .output, + ).ok, + false, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('successful pending globe admission releases a queued ordinary tool', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller); + const reset = invoke(controller, { + callId: 'allowed-reset', + name: 'zoom_to_globe', + args: {}, + }); + assert.deepEqual(calls, ['move_camera']); + finishRotation({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('rotation waits for an earlier reset before evaluating the globe', async () => { + let finishReset; + let atGlobe = false; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name === 'zoom_to_globe') { + await pending; + atGlobe = true; + return { ok: true, action: name }; + } + return atGlobe + ? { ok: true, action: name, motion: 'rotate_globe' } + : refusal; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['first-reset', 'rotation-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, undefined); +}); + +for (const outcome of [ + 'cancelled', + 'thrown', + 'retry-refused', + 'settled-failure', +]) { + test(`a ${outcome} reset sequence cannot suggest another reset`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name !== 'zoom_to_globe') return refusal; + await pending; + if (outcome === 'thrown') throw new Error('reset failed'); + return { + ok: outcome === 'retry-refused', + action: name, + cancelled: outcome === 'cancelled', + }; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (outcome === 'settled-failure') { + finishReset(); + await reset; + } + const rotation = invoke(controller); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual( + calls, + outcome === 'retry-refused' + ? ['zoom_to_globe', 'move_camera'] + : ['zoom_to_globe'], + ); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.ok, false); + assert.equal(result.reason, 'globe-reset-failed'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.doesNotMatch( + replies[0].response.instructions, + /Reset to Globe View and rotate the globe/, + ); + }); +} + +test( + 'Stop releases a rotation waiting for reset and isolates a reused response id', + { timeout: 1000 }, + async () => { + let finishOld, finishNew; + const oldReset = new Promise((resolve) => { + finishOld = resolve; + }); + const newReset = new Promise((resolve) => { + finishNew = resolve; + }); + let resets = 0; + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? ++resets === 1 + ? oldReset + : newReset + : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const old = invoke(controller, { + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + const waiter = invoke(controller); + controller.stop(); + await waiter; + assert.deepEqual(calls, ['zoom_to_globe']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); + const replacement = invoke(controller, { + callId: 'new-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller, { callId: 'new-rotation' }); + finishOld({ ok: false, action: 'zoom_to_globe' }); + await old; + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe']); + assert.equal(sent.length, 0); + finishNew({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([replacement, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); + assert.deepEqual( + sent + .filter((event) => event.item?.type === 'function_call_output') + .map((event) => event.item.call_id), + ['new-reset', 'new-rotation'], + ); + }, +); + +test( + 'a typed turn releases rotation without waiting for its reset runner', + { timeout: 1000 }, + async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(() => pending); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'stale-first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + // The tool response can finish while its reset and rotation still wait. + complete(controller); + controller.sendTextCommand('pan left'); + await rotation; + assert.deepEqual(calls, ['zoom_to_globe']); + const output = sent.find( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(JSON.parse(output.item.output).superseded, true); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'typed-response'); + complete(controller, 'typed-response'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + finishReset({ ok: false, action: 'zoom_to_globe' }); + await reset; + // This protects rotation's cancellation only. Ordinary reset-result + // follow-ups after supersession remain the inherited sibling-tool limit. + }, +); + +test('an aborted reset still blocks a late same-response rotation', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'aborted-reset', + name: 'zoom_to_globe', + args: {}, + }); + try { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'globe-reset-failed'); + } finally { + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + } + complete(controller); + assert.equal( + sent.find((event) => event.type === 'response.create').response.tool_choice, + 'none', + ); +}); + +test('an earlier tool finishing late cannot replace the globe refusal followup', async () => { + let finishLookup; + const pending = new Promise((resolve) => { + finishLookup = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : pending, + ); + begin(controller); + const lookup = invoke(controller, { + callId: 'lookup-call', + name: 'get_current_view_state', + args: {}, + }); + await invoke(controller); + finishLookup({ ok: true, action: 'get_current_view_state' }); + await lookup; + complete(controller); + assert.deepEqual(calls, ['get_current_view_state', 'move_camera']); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('a duplicate queued behind successive admissions cannot outlive the dedupe window', async (t) => { + let clock = 0; + t.mock.method(performance, 'now', () => clock); + let finishFirst, finishSecond, secondStarted; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const second = new Promise((resolve) => { + finishSecond = resolve; + }); + const enteredSecond = new Promise((resolve) => { + secondStarted = resolve; + }); + let executions = 0; + const { controller, sent, calls } = harness(() => { + if (++executions === 1) return first; + secondStarted(); + return second; + }); + begin(controller); + const original = invoke(controller); + const queued = invoke(controller, { callId: 'queued-rotation' }); + const duplicate = controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.output_item.done', + response_id: 'rotation-response', + item: { + type: 'function_call', + id: 'queued-rotation-item', + call_id: 'queued-rotation', + name: 'move_camera', + arguments: JSON.stringify({ motion: 'rotate_globe' }), + }, + }), + }); + finishFirst({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await enteredSecond; + clock += CALL_DEDUPE_MS + 1; + finishSecond({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([original, queued, duplicate]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'queued-rotation'], + ); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); +}); + +test( + 'Stop releases an admission waiter and stale completion cannot affect its replacement', + { timeout: 1000 }, + async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const rotation = invoke(controller); + const queued = invoke(controller, { + callId: 'stopped-reset', + name: 'zoom_to_globe', + args: {}, + }); + controller.stop(); + await queued; // Must settle even though the original runner has not completed. + assert.equal(oldChannel.readyState, 'closed'); + assert.deepEqual(calls, ['move_camera']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); // Reuse the id to expose any leaked old refusal state. + finishRotation(refusal); + await rotation; + assert.equal(sent.length, 0); + await invoke(controller, { + callId: 'replacement-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['replacement-reset'], + ); + assert.equal(JSON.parse(outputs[0].item.output).ok, true); + assert.equal( + sent.find((event) => event.type === 'response.create').response + .tool_choice, + undefined, + ); + }, +); + +for (const completedFirst of [false, true]) { + test(`a newer typed turn retires its queued sibling after response completion: ${completedFirst}`, async () => { + let finishRotation; + let oldSignal; + let abortObserved = false; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name, args, context) => { + if (args.motion === 'rotate_globe') { + oldSignal = context.signal; + oldSignal.addEventListener('abort', () => { + abortObserved = true; + }); + return pending; + } + return { ok: true, action: name }; + }); + // Use the actual cancellation owner rather than the lightweight harness stub. + delete controller.cancelRadioHandoff; + begin(controller); + const rotation = invoke(controller); + const sibling = invoke(controller, { + callId: 'stale-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (completedFirst) complete(controller); + controller.sendTextCommand('pan left'); + assert.equal(oldSignal.aborted, true); + assert.equal(abortObserved, true); + finishRotation(refusal); + await Promise.all([rotation, sibling]); + if (!completedFirst) complete(controller); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'stale-reset'], + ); + assert.equal(JSON.parse(outputs[1].item.output).superseded, true); + assert.deepEqual(calls, ['move_camera']); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'new-turn-response'); + await invoke(controller, { + responseId: 'new-turn-response', + callId: 'new-pan', + args: { motion: 'pan', direction: 'left' }, + }); + complete(controller, 'new-turn-response'); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const finalOutput = sent + .filter((event) => event.item?.type === 'function_call_output') + .at(-1); + assert.equal(finalOutput.item.call_id, 'new-pan'); + assert.equal(JSON.parse(finalOutput.item.output).ok, true); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + ?.tool_choice, + undefined, + ); + }); +} + +test('a newer typed turn during visual context cannot revive the old refusal followup', async () => { + let finishContext; + let enterContext; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pending = new Promise((resolve) => { + finishContext = resolve; + }); + const { controller, sent } = harness(refusal); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async () => { + enterContext(); + await pending; + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + finishContext(); + await rotation; + complete(controller); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 1, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('stale globe completion preserves a newer tool status until that tool finishes', async () => { + let finishContext; + let enterContext; + let finishPan; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pendingContext = new Promise((resolve) => { + finishContext = resolve; + }); + const pendingPan = new Promise((resolve) => { + finishPan = resolve; + }); + const { controller, sent } = harness((name, args) => + args.motion === 'rotate_globe' ? refusal : pendingPan, + ); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async (result) => { + if (result === refusal) { + enterContext(); + await pendingContext; + } + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + complete(controller); + begin(controller, 'new-pan-response'); + const pan = invoke(controller, { + responseId: 'new-pan-response', + callId: 'new-pan-call', + args: { motion: 'pan', direction: 'left' }, + }); + assert.equal(controller.status, 'executing'); + finishContext(); + await rotation; + assert.equal(controller.status, 'executing'); + assert.equal(controller.pendingResponseInstructions, null); + finishPan({ ok: true, action: 'move_camera', motion: 'pan' }); + await pan; + complete(controller, 'new-pan-response'); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + assert.equal( + sent + .filter((event) => event.type === 'response.create') + .every((event) => event.response?.tool_choice === undefined), + true, + ); +}); + +const rotated = { + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', +}; +/** A close-up camera that fits only after an executed reset. */ +function closeUpCamera() { + const state = { atGlobeView: false, resets: 0 }; + const result = (name) => { + if (name === 'zoom_to_globe') { + state.resets += 1; + state.atGlobeView = true; + return { ok: true, action: 'zoom_to_globe', cancelled: false }; + } + return state.atGlobeView ? rotated : refusal; + }; + return { state, result }; +} +test('OpenAI: explicit reset and reset-then-rotate stay direct', async () => { + const camera = closeUpCamera(); + const { controller, calls } = harness(camera.result); + await speak(controller); + begin(controller, 'reset'); + await invoke(controller, { + responseId: 'reset', + callId: 'explicit-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset'); + assert.equal(camera.state.resets, 1); + // A fresh "Reset Globe View and rotate" resets, then rotates in order. + camera.state.atGlobeView = false; + await speak(controller); + begin(controller, 'both'); + await invoke(controller, { + responseId: 'both', + callId: 'reset-first', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { responseId: 'both', callId: 'then-rotate' }); + complete(controller, 'both'); + assert.equal(camera.state.resets, 2); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); +}); + +test('a failed reset survives real tool followups and a rejected typed send', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? { ok: false, action: name } : rotated, + ); + begin(controller, 'reset-response'); + await invoke(controller, { + responseId: 'reset-response', + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + for (const id of ['followup', 'after-rejected-text']) { + if (id === 'after-rejected-text') { + const send = controller.sendRealtimeEvent; + controller.sendRealtimeEvent = (event, source) => + source === 'client.user_text' ? false : send(event, source); + controller.sendTextCommand('rotate the globe'); + controller.sendRealtimeEvent = send; + } + begin(controller, id); + await invoke(controller, { responseId: id, callId: `${id}-rotation` }); + complete(controller, id); + const output = sent.find( + (event) => event.item?.call_id === `${id}-rotation`, + ); + assert.equal(JSON.parse(output.item.output).reason, 'globe-reset-failed'); + assert.equal(JSON.parse(output.item.output).skipped, true); + } + assert.deepEqual(calls, ['zoom_to_globe']); + const reply = sent.filter((event) => event.type === 'response.create').at(-1); + assert.equal(reply.response.tool_choice, 'none'); + assert.doesNotMatch( + reply.response.instructions, + /Reset to Globe View and rotate the globe/, + ); +}); + +test('failed reset skips waiting rotation without blocking queued Stop or lookup', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name, args) => + name === 'zoom_to_globe' + ? pending + : { ok: true, action: name, motion: args.motion }, + ); + begin(controller); + const reset = invoke(controller, { + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + const stop = invoke(controller, { + callId: 'queued-stop', + args: { motion: 'stop' }, + }); + const lookup = invoke(controller, { + callId: 'queued-lookup', + name: 'get_current_view_state', + args: {}, + }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await Promise.all([reset, rotation, stop, lookup]); + complete(controller); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'move_camera', + 'get_current_view_state', + ]); + for (const id of ['queued-stop', 'queued-lookup']) { + const output = sent.find((event) => event.item?.call_id === id); + assert.equal(JSON.parse(output.item.output).ok, true); + } + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).skipped, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); +}); + +for (const input of ['text', 'speech']) { + test(`aborted reset stays failed across followups; fresh ${input} isolates late settlement`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : rotated, + ); + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset'); + const reset = invoke(controller, { + responseId: 'old-reset', + callId: 'old-reset-call', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + complete(controller, 'old-reset'); + begin(controller, 'old-followup'); + await invoke(controller, { + responseId: 'old-followup', + callId: 'blocked-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe']); + const skipped = sent.find( + (event) => event.item?.call_id === 'blocked-rotation', + ); + assert.equal(JSON.parse(skipped.item.output).reason, 'globe-reset-failed'); + if (input === 'text') controller.sendTextCommand('rotate the globe'); + else await speak(controller); + begin(controller, 'fresh'); + await invoke(controller, { responseId: 'fresh', callId: 'fresh-rotation' }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + await invoke(controller, { + responseId: 'fresh', + callId: 'after-old-settlement', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera', 'move_camera']); + }); +} + +test('a real tool followup waits for a reset still pending in the previous response', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + begin(controller, 'reset-response'); + const reset = invoke(controller, { + responseId: 'reset-response', + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { + responseId: 'reset-response', + callId: 'fast-lookup', + name: 'get_current_view_state', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + assert.equal( + JSON.parse( + sent.find((event) => event.item?.call_id === 'fast-lookup').item.output, + ).ok, + true, + ); + begin(controller, 'followup'); + const rotation = invoke(controller, { + responseId: 'followup', + callId: 'waiting-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'get_current_view_state']); + finishReset({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([reset, rotation]); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'get_current_view_state', + 'move_camera', + ]); + assert.equal( + sent.filter((event) => event.item?.call_id === 'waiting-rotation').length, + 1, + ); +}); + +test('a recoverable collision keeps a completed reset successful for its rotation', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: true, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + await invoke(controller, { + callId: 'done-reset', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).ok, true); +}); + +test('an old response reset cannot poison a fresh spoken rotation request', async () => { + for (const lateReset of [false, true]) { + const { controller, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: false, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + // Use the production speech-start cancellation wiring, not the harness stub. + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset-response'); + if (lateReset) await speak(controller); + await invoke(controller, { + responseId: 'old-reset-response', + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (!lateReset) await speak(controller); + begin(controller, 'fresh-rotation-response'); + await invoke(controller, { + responseId: 'fresh-rotation-response', + callId: 'fresh-rotation', + }); + assert.deepEqual( + calls, + ['zoom_to_globe', 'move_camera'], + lateReset + ? 'late old reset must retain its original request' + : 'fresh speech clears prior reset failure', + ); + } +}); + +test('a late rotation without a reset retains existing arrival-time policy', async () => { + const { controller, calls } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + }); + delete controller.cancelRadioHandoff; + begin(controller, 'old-rotation-response'); + await speak(controller); + await invoke(controller, { + responseId: 'old-rotation-response', + callId: 'late-rotation', + }); + assert.deepEqual(calls, ['move_camera']); +}); diff --git a/src/voice/realtimeProtocol.js b/src/voice/realtimeProtocol.js index 14105229c..a721bb7c9 100644 --- a/src/voice/realtimeProtocol.js +++ b/src/voice/realtimeProtocol.js @@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) { ); } +export function isGlobeViewRefusal(result) { + return ( + result?.action === 'move_camera' && + result.ok === false && + [ + 'globe-view-required', + 'globe-reset-unavailable', + 'globe-reset-failed', + ].includes(result.reason) + ); +} + export function responseInstructionForToolResult(result) { if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) { if (result.audioState === 'paused') { @@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) { : 'Be concise: useful names, place, layer and one or two notable properties.', ].join(' '); } + if (isGlobeViewRefusal(result)) { + // A refusal is not a completed action. Keep tool text out of instructions. + if (result.reason === 'globe-reset-failed') + return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.'; + if (result.reason === 'globe-reset-unavailable') + return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.'; + return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.'; + } + if ( + (result?.action ?? result?.tool) === 'zoom_to_globe' && + (result.ok !== true || result.cancelled) + ) { + return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.'; + } if (result?.action === 'adjust_camera_zoom') { return result.ok ? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.` diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index 2073be06d..29c4b3bc0 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -6,6 +6,7 @@ import { SUPERSEDED_RESPONSE_MEMORY, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, extractFunctionCalls, callDedupeKeys, parseArguments, @@ -66,6 +67,11 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds = new Set(); + this.globeRefusals = new Map(); + this.globeAdmissionGates = new Map(); + this.globeResets = new Map(); + this.globeResponseRequests = new Map(); + this.globeRequest = { failed: false, pending: new Set() }; this.activeToolAbortControllers = new Set(); this.narration = null; this.narrationSequence = 0; @@ -138,6 +144,7 @@ export class RealtimeTurns { }, }; if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return; + this.globeRequest = { failed: false, pending: new Set() }; this.requestUserTextResponse(); } @@ -330,6 +337,7 @@ export class RealtimeTurns { this.userTurnPending = true; this.pendingResponseInstructions = null; this.cancelRadioHandoff({ abortTools: true }); + this.globeRequest = { failed: false, pending: new Set() }; this.setVoiceSpeaker('user'); // Point-and-ask: read the pointer as speech starts (open mic), or reuse // the push-to-talk keydown snapshot, before the user audio commits. @@ -384,6 +392,27 @@ export class RealtimeTurns { } const toolResponseId = payload.response_id || payload.response?.id || null; + // Follow-up responses retain reset ownership until fresh admitted input. + const arrivalRequest = this.globeRequest; + const globeRequest = + this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest; + // Later siblings must wait for globe admission before they can mutate state. + // Other tool responses keep their existing concurrency. + let heldForAdmission = false; + while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) { + // Drop claimed duplicates before every wait, before their keys can expire. + if ( + calls.every((call) => + callDedupeKeys(call).some((key) => this.processedCalls.has(key)), + ) + ) + return; + heldForAdmission = true; + await this.globeAdmissionGates.get(toolResponseId).promise; + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + } + // A newer typed command superseded the response these calls belong to. // They are stale intent — dispatching one would let the old turn mutate // the map after the operator asked for something else. @@ -394,7 +423,10 @@ export class RealtimeTurns { // hazard `callDedupeKeys` is written to avoid). So each refused call gets // a terminal output saying plainly that the turn moved on. No // `response.create` follows — the deferred typed turn is the single answer. - if (this.isSupersededResponse(toolResponseId)) { + if ( + this.isSupersededResponse(toolResponseId) || + (heldForAdmission && arrivalRequest !== this.globeRequest) + ) { this.pruneProcessedCalls(); for (const call of calls) { const keys = callDedupeKeys(call); @@ -429,7 +461,19 @@ export class RealtimeTurns { if (keys.some((key) => this.processedCalls.has(key))) continue; keys.forEach((key) => this.processedCalls.set(key, performance.now())); + if (this.globeRefusals.get(toolResponseId)?.blocking) { + this.sendToolOutput(call.call_id || call.id, { + ok: false, + action: call.name, + skipped: true, + error: 'Not run: this response must wait for the Globe View reply.', + }); + continue; + } + let result; + let globeAdmissionGate = null; + let globeResetGate = null; const resultChannel = this.dc; let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch; let toolController = null; @@ -443,6 +487,80 @@ export class RealtimeTurns { this.narration?.preamble(); try { const parsedArguments = parseArguments(call.arguments); + if ( + toolResponseId && + call.name === 'move_camera' && + String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe' + ) { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + globeAdmissionGate = { promise, resolve }; + this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate); + // OpenAI delivers sibling calls concurrently. A reset dispatched + // first must finish before rotation evaluates the camera's fit. + // Hold later siblings at admission while this rotation waits. + const resets = this.globeResets.get(toolResponseId); + if ( + resets || + globeRequest.failed || + globeRequest.pending.size || + arrivalRequest !== this.globeRequest + ) { + while (resets?.pending.size || globeRequest.pending.size) + await Promise.all( + [ + ...new Set([ + ...(resets?.pending || []), + ...globeRequest.pending, + ]), + ].map((gate) => gate.promise), + ); + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + keys.forEach((key) => + this.processedCalls.set(key, performance.now()), + ); + const superseded = + this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest; + if (superseded || resets?.failed || globeRequest.failed) { + result = { + ok: false, + action: call.name, + reason: 'globe-reset-failed', + skipped: true, + ...(superseded ? { superseded: true } : {}), + error: superseded + ? 'Superseded by a newer command from the operator — this call was not run.' + : 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + }; + } + } + } + if (toolResponseId && call.name === 'zoom_to_globe') { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + let resets = this.globeResets.get(toolResponseId); + if (!resets) { + resets = { pending: new Set(), failed: false }; + this.globeResets.set(toolResponseId, resets); + // Retain settled outcomes for later siblings, without evicting an + // active reset that another response still needs to await. + for (const [id, record] of this.globeResets) { + if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break; + if (!record.pending.size && id !== toolResponseId) + this.globeResets.delete(id); + } + } + globeResetGate = { promise, resolve, resets, request: globeRequest }; + resets.pending.add(globeResetGate); + globeRequest.pending.add(globeResetGate); + } + const isRadioControlCall = call.name === 'control_radio'; const isRadioVisibilityCall = call.name === 'set_layer_visibility' && @@ -499,7 +617,7 @@ export class RealtimeTurns { authorityDomain: radioAuthorityDomain, }); } - result = await (this.actionExecutor || this.runner)( + result ??= await (this.actionExecutor || this.runner)( call.name, parsedArguments, { @@ -515,7 +633,28 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if ( + globeAdmissionGate && + this.globeResets.has(toolResponseId) && + result?.reason === 'globe-view-required' + ) + result = { ...result, reason: 'globe-reset-failed' }; if (!this.ownsConversation(eventChannel)) return; + if ( + isGlobeViewRefusal(result) && + toolResponseId && + arrivalRequest === this.globeRequest && + !this.isSupersededResponse(toolResponseId) + ) { + this.globeRefusals.set(toolResponseId, { + result, + followupQueued: false, + blocking: !result.skipped, + }); + while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) { + this.globeRefusals.delete(this.globeRefusals.keys().next().value); + } + } if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -595,6 +734,23 @@ export class RealtimeTurns { : {}), }; } finally { + if (globeResetGate) { + const { resets, request } = globeResetGate; + if (result?.ok !== true || result.cancelled) { + resets.failed = true; + request.failed = true; + } + resets.pending.delete(globeResetGate); + request.pending.delete(globeResetGate); + globeResetGate.resolve(); + } + if (globeAdmissionGate) { + if ( + this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate + ) + this.globeAdmissionGates.delete(toolResponseId); + globeAdmissionGate.resolve(); + } if (toolController) { this.activeToolAbortControllers.delete(toolController); this.radio.releaseTool(toolController); @@ -671,13 +827,38 @@ export class RealtimeTurns { }); } if (!this.ownsConversation(eventChannel)) return; - // Keep the Radio handoff wording authoritative even when another tool - // result follows Radio in the same multi-intent response. - this.queueResponseCreate( - responseInstructionForToolResult( - this.radio.pendingRadioPlaybackResult || lastResult, - ), - ); + const refusal = this.globeRefusals.get(toolResponseId); + // A newer turn may arrive during admission or the viewport-image await. + // Preserve its reply; neither the refusal map nor lastResult may revive + // an old refusal follow-up after supersession. + if ( + (this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest) && + (refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded) + ) { + // Complete an idle turn below, but leave a newer running tool's status + // authoritative while suppressing this obsolete refusal follow-up. + if (this.activeToolAbortControllers.size) return; + } else if (refusal) { + // Even an earlier sibling finishing late cannot replace the reset instruction. + if (!refusal.followupQueued) { + refusal.followupQueued = true; + this.queueResponseCreate({ + instructions: responseInstructionForToolResult(refusal.result), + tool_choice: 'none', + }); + } + } else { + // Keep Radio handoff wording authoritative for ordinary multi-tool replies. + const followupResult = + this.radio.pendingRadioPlaybackResult || lastResult; + const instructions = responseInstructionForToolResult(followupResult); + this.queueResponseCreate( + isGlobeViewRefusal(followupResult) + ? { instructions, tool_choice: 'none' } + : instructions, + ); + } } this.setStatus('listening', t('Ask or command')); } @@ -966,6 +1147,22 @@ export class RealtimeTurns { this.userTurnPending = false; this.activeResponseId = payload.response?.id || payload.response_id || null; + // Late calls retain only their globe reset/request ownership. Ordinary + // tool admission and concurrency continue to use their existing policy. + if ( + this.activeResponseId && + !this.globeResponseRequests.has(this.activeResponseId) + ) { + this.globeResponseRequests.set( + this.activeResponseId, + this.globeRequest, + ); + while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY) + this.globeResponseRequests.delete( + this.globeResponseRequests.keys().next().value, + ); + } + this.setVoiceSpeaker('ai'); return; } @@ -1045,7 +1242,8 @@ export class RealtimeTurns { const sent = this.sendRealtimeEvent( { type: 'response.create', - response: { instructions }, + response: + typeof instructions === 'string' ? { instructions } : instructions, }, 'client.response_create.tool_followup', ); @@ -1057,6 +1255,20 @@ export class RealtimeTurns { controller.abort(); this.activeToolAbortControllers.clear(); this.radio.clearTools(); + this.releaseGlobeResets(); + } + + releaseGlobeResets() { + for (const resets of this.globeResets.values()) { + if (!resets.pending.size) continue; + resets.failed = true; + for (const gate of resets.pending) { + gate.request.failed = true; + gate.request.pending.delete(gate); + gate.resolve(); + } + resets.pending.clear(); + } } reset() { @@ -1068,6 +1280,13 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds.clear(); + this.globeRefusals.clear(); + for (const gate of this.globeAdmissionGates.values()) gate.resolve(); + this.globeAdmissionGates.clear(); + this.releaseGlobeResets(); + this.globeResets.clear(); + this.globeResponseRequests.clear(); + this.globeRequest = { failed: false, pending: new Set() }; this.narration?.cancel(); this.assistantAudioPlaying = false; this.speechResponseIds.clear(); From 8d454bf7f189bbd1264569a6b024f907a89c6017 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 15:05:23 -0700 Subject: [PATCH 16/25] fix: improve mobile contact selection and context drawers (#1338) Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo. --- changelog.d/1338-mobile-contact-context.md | 4 + docs/CURRENT-STATE.md | 17 +- scripts/package-boundaries.json | 1 + src/data/aisLiveVessels.test.mjs | 28 ++ src/data/inputOwnership.test.mjs | 5 +- src/data/pickRegistry.js | 35 ++ src/data/pickRegistry.test.mjs | 120 +++++++ src/layers/flights/tracking.js | 6 +- src/layers/military/tracking.js | 6 +- src/layers/vessels/selection.js | 6 +- src/ui/applicationShell.js | 29 ++ src/ui/mobileShell.js | 120 +++++++ src/ui/mobileShell.test.mjs | 388 ++++++++++++++++++++- src/ui/styles/mobile-shell.css | 6 +- 14 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 changelog.d/1338-mobile-contact-context.md diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 994f9a623..b0b696b96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1399,11 +1399,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), Cameras (CCTV and Street Level), Context (Global Context, plus Weather and Recent Imagery when shown), Search (Location) and More (Visual Presets and Map Source above Display). A tab opens one bottom sheet; the same tab, the backdrop -or Escape closes it, and focus moves into the sheet and back to its tab. Panels +or Escape closes it, and focus moves into the sheet and back to its tab. A +downward swipe of at least 64px on non-control sheet content also closes it, +when every scroll container under the finger starts at the top. Ordinary +vertical scrolling, horizontal gestures, controls and multitouch stay native; +a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock as the sheet, and the dock's Location and Presets trays render inline. The voice control floats above the tab bar while no sheet is open. +The phone logo is 58px wide (about 39px high), aligned with the top controls. +The shared contact picker preserves exact picks. For a coarse primary pointer +that misses onto the map surface, it searches at most eight hits in a 14px square +and accepts the first registered layer hit, stopping at non-map objects. Civil +and military aircraft and vessels share this rule; fine pointers stay exact. + +Manually opening Context with a tracked civilian or military aircraft enters +the existing Contacts mode when Context is off. Contacts adopts that aircraft +without restarting its tracking; an active or entering Space Missions mode is +left alone. Programmatic sheet changes and closing the tab do not activate a mode. + Sheets borrow panels. Opening one expands its panels without persisting them or claiming a user choice, and share links keep reporting each borrowed panel's state from before the sheet; closing puts back what opening changed, while a diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..3970a1ee2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3672,6 +3672,7 @@ "src/data/labelArbiter.js", "src/data/meshFloorSampler.js", "src/data/pickRegistry.js", + "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index b5a2c8de9..96a5cd105 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( } }); +test('vessel touch interaction yields to the first nearby flight behind map tiles', () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }); + registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); + const tile = { primitive: { isCesium3DTileset: true } }; + const harness = installWireHarness(tile); + let drills = 0; + harness.viewer.scene.drillPick = (_position, limit, width, height) => { + drills++; + assert.deepEqual([limit, width, height], [8, 14, 14]); + return [tile, { id: 'a1b2c3' }]; + }; + try { + harness.handler.click({ position: { x: 10, y: 20 } }); + assert.equal(drills, 1); + assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal(harness.trail.clearCalls, 0); + } finally { + harness.cleanup(); + unregisterPickOwner('flights'); + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + } +}); + test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => { registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3'); const harness = installWireHarness({ id: 'atx-cam-3' }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 9b20f3ad4..718ef7068 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/vessels/selection.js', + 'pickContact(viewer.scene, click.position)', + ], ]; for (const [file, firstPick] of guarded) { const source = read(file); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index e54ef6b29..ef3d8074e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -11,6 +11,8 @@ * that belong to someone else and leave them alone. */ +import { isSurfacePick } from './scenePick.js'; + /** @type {Map boolean>} layerId -> ownership predicate */ const _owners = new Map(); @@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) { } return false; } + +/** + * Resolve a moving-contact click without letting map tiles hide a nearby touch + * target. Exact picks always win; only a coarse primary pointer may expand a + * surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers + * do not each choose their own contact under one tap. + * Unowned non-surface objects remain barriers, not permission to pick through. + * + * @param {object} scene Cesium scene with pick and optional drillPick. + * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @returns {object|null|undefined} Original pick or the first registered touch hit. + */ +export function pickContact(scene, position) { + const picked = scene.pick(position); + if ( + !globalThis.matchMedia?.('(pointer: coarse)').matches || + findPickOwner(resolvePickId(picked)) || + !isSurfacePick(picked) || + typeof scene.drillPick !== 'function' + ) + return picked; + let hits; + try { + hits = scene.drillPick(position, 8, 14, 14) || []; + } catch { + return picked; + } + for (const hit of hits.slice(0, 8)) { + if (findPickOwner(resolvePickId(hit))) return hit; + if (!isSurfacePick(hit)) break; + } + return picked; +} diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..43f11260f 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -16,6 +16,7 @@ import { unregisterPickOwner, isOwnedByOtherLayer, resolvePickId, + pickContact, } from './pickRegistry.js'; // --------------------------------------------------------------------------- @@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +function primaryPointer(t, coarse) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); +} + +function contactOwners(t) { + registerPickOwner('flights', (id) => id === 'civil'); + registerPickOwner('military', (id) => id === 'military'); + registerPickOwner('ais-live-vessels', (id) => id === '123456789'); + t.after(() => { + for (const id of ['flights', 'military', 'ais-live-vessels']) + unregisterPickOwner(id); + }); +} + +const mapTile = { primitive: { isCesium3DTileset: true } }; + +test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => { + primaryPointer(t, true); + contactOwners(t); + const position = { x: 30, y: 50 }; + const contact = { primitive: { id: 'civil' } }; + let drills = 0; + const scene = { + pick(point) { + assert.equal(point, position); + return mapTile; + }, + drillPick(...args) { + drills++; + assert.deepEqual(args, [position, 8, 14, 14]); + return [mapTile, contact]; + }, + }; + assert.equal(pickContact(scene, position), contact); + assert.equal(drills, 1); +}); + +test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + const scene = { + pick: () => mapTile, + drillPick: () => [mapTile, first, { id: 'civil' }], + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), first); + } +}); + +test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const exact of [ + { id: 'military' }, + { id: 'custom-annotation' }, + { primitive: { id: 'custom-gizmo' } }, + { primitive: { id: 'civil', isCesium3DTileset: true } }, + ]) { + const scene = { + pick: () => exact, + drillPick: () => assert.fail('must keep exact interactive hit'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), exact); + } +}); + +test('touch expansion stops at a non-surface object and respects the drill bound', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const hits of [ + [mapTile, { id: 'custom-annotation' }, { id: 'civil' }], + [...Array(8).fill(mapTile), { id: 'civil' }], + [mapTile], + ]) { + assert.equal( + pickContact( + { pick: () => mapTile, drillPick: () => hits }, + { x: 1, y: 1 }, + ), + mapTile, + ); + } +}); + +test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => { + primaryPointer(t, false); + contactOwners(t); + const scene = { + pick: () => mapTile, + drillPick: () => assert.fail('mouse must not expand'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile); +}); + +test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const drillPick of [ + undefined, + () => { + throw new Error('context lost'); + }, + () => [], + ]) { + assert.equal( + pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }), + undefined, + ); + } +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cef9e1563..dde8f4f95 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1174,7 +1174,9 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 3530a4877..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 7c866bc36..4552da8d4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -864,9 +864,38 @@ export class StyleManager extends ShellFacade { setPanelCollapsed: (panelId, collapsed, options) => this._panelChrome.setPanelCollapsed(panelId, collapsed, options), onSheetsClosed: () => this._syncShareState(), + onTabOpened: (sheet) => { + void this._onMobileTabOpened(sheet); + }, }); } + /** Reveal a tracked aircraft through Contacts when its phone tab is opened. */ + async _onMobileTabOpened(sheet) { + const context = this.getContextModeState(); + if ( + sheet !== 'context' || + this._disposed || + !context || + context.mode || + context.changing || + context.entering || + !this.getAircraftTrackingTarget() + ) + return false; + try { + const result = await this.setContextMode('contacts'); + if (result?.ok) return true; + if (result?.cancelled || this._disposed) return false; + } catch { + if (this._disposed) return false; + } + this._showToast( + t('Contacts could not complete the requested transition; try again'), + ); + return false; + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index a2b8638b2..2363fd9ed 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -125,6 +125,8 @@ function buildChrome(documentRef) { * @param {(callback: () => void) => number} [input.requestFrame] * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes * and its panels are put back (e.g. to refresh the share link). + * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user + * opens a sheet from its tab, never on close or programmatic presentation. * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} */ export function initMobileShell({ @@ -135,6 +137,7 @@ export function initMobileShell({ ResizeObserverImpl = globalThis.ResizeObserver, requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), onSheetsClosed = () => {}, + onTabOpened = () => {}, } = {}) { const body = documentRef.body; const { backdrop, tabBar } = buildChrome(documentRef); @@ -159,6 +162,7 @@ export function initMobileShell({ */ let borrowed = null; let returnFocus = null; + let swipe = null; const written = new Map(); const writeVar = (name, value) => { @@ -259,6 +263,7 @@ export function initMobileShell({ }; const setSheet = (requested) => { + swipe = null; if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; const sheet = sheetBlocked(body) ? null : requested; const previous = currentSheetOf(body); @@ -348,8 +353,123 @@ export function initMobileShell({ // Closing from the tab bar returns focus to the tab that was pressed. if (!sheet) returnFocus = button; setSheet(sheet); + if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); }); listen(backdrop, 'click', () => setSheet(null)); + // Touch events let native scrolling continue until a downward pull at the + // top is intentional. Pointer capture/touch-action:none would also seize + // ordinary vertical scrolling inside the borrowed panels. + const sheetRoot = (target, sheet) => { + const roots = + sheet === 'layers' + ? [element('left-panel-stack')] + : sheet === 'search' + ? [dock] + : sheet === 'more' + ? [element('right-context-rail'), dock] + : [element('right-context-rail')]; + return roots.find((root) => root?.contains(target)); + }; + const scrolled = (target, root) => { + for (let node = target; node; node = node.parentElement) { + if (node.scrollTop > 0) return true; + if (node === root) break; + } + return false; + }; + listen( + documentRef, + 'touchstart', + (event) => { + swipe = null; + const sheet = currentSheetOf(body); + const target = event.target; + if ( + !sheet || + !body.classList.contains(MOBILE_SHELL_CLASS) || + event.defaultPrevented || + event.touches.length !== 1 || + target?.closest?.( + 'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]', + ) + ) + return; + const root = sheetRoot(target, sheet); + if (!root || scrolled(target, root)) return; + const touch = event.touches[0]; + swipe = { + sheet, + target, + root, + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + claimed: false, + }; + }, + { passive: true }, + ); + listen( + documentRef, + 'touchmove', + (event) => { + if (!swipe) return; + const touch = event.touches[0]; + if ( + event.defaultPrevented || + event.touches.length !== 1 || + touch.identifier !== swipe.id || + currentSheetOf(body) !== swipe.sheet || + scrolled(swipe.target, swipe.root) + ) { + swipe = null; + return; + } + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + if (!swipe.claimed) { + // Claim direction before the browser crosses its native scroll slop. + // Android can make the next move non-cancelable after a ~9px first move. + if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return; + if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) { + swipe = null; + return; + } + swipe.claimed = true; + } + // Cancelling only the claimed pull leaves taps, sliders and normal scroll + // native, and suppresses the synthetic click at the end of a dismissal. + event.preventDefault(); + }, + { passive: false }, + ); + listen( + documentRef, + 'touchend', + (event) => { + const pull = swipe; + swipe = null; + if (!pull?.claimed || event.touches.length) return; + const touch = [...event.changedTouches].find( + (item) => item.identifier === pull.id, + ); + if (!touch || currentSheetOf(body) !== pull.sheet) return; + const dx = Math.abs(touch.clientX - pull.x); + const dy = touch.clientY - pull.y; + if (dy < 64 || dy <= dx * 1.25) return; + returnFocus = tabFor(pull.sheet) ?? returnFocus; + setSheet(null); + }, + { passive: true }, + ); + listen( + documentRef, + 'touchcancel', + () => { + swipe = null; + }, + { passive: true }, + ); // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools // take Escape first; panels yield it to the sheet (PanelChrome), and an open // dialog keeps its own native close. diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index 751333c9a..bb7599547 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; +import { ContextControls } from './contextControls.js'; import { MOBILE_SHEET_PANELS, MOBILE_TABS, @@ -21,6 +23,8 @@ function element(tag, document) { textContent: '', className: '', parent: null, + scrollTop: 0, + parentElement: null, classList: { add: (...names) => names.forEach((name) => classes.add(name)), remove: (...names) => names.forEach((name) => classes.delete(name)), @@ -41,6 +45,7 @@ function element(tag, document) { append(...children) { for (const child of children) { child.parent = node; + child.parentElement = node; node.children.push(child); if (child.id) document.ids.set(child.id, child); } @@ -56,9 +61,20 @@ function element(tag, document) { return false; }, closest(selector) { - for (let current = node; current; current = current.parent) + for (let current = node; current; current = current.parent) { + if ( + selector.startsWith('button, a') && + (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes( + current.tagName, + ) || + ['slider', 'button'].includes(current.getAttribute('role')) || + (current.getAttribute('contenteditable') !== null && + current.getAttribute('contenteditable') !== 'false')) + ) + return current; if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) return current; + } return null; }, querySelectorAll(selector) { @@ -94,7 +110,11 @@ function element(tag, document) { const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; -function fixture({ mobile = true, expanded = [] } = {}) { +function fixture({ + mobile = true, + expanded = [], + onTabOpened = () => {}, +} = {}) { const document = new EventTarget(); document.ids = new Map(); document.createElement = (tag) => element(tag, document); @@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) { const search = element('input', document); search.id = 'location-search'; document.body.append(search); + const rails = {}; + for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) { + rails[id] = element('div', document); + rails[id].id = id; + document.body.append(rails[id]); + } for (const id of PANEL_IDS) { const panel = element('section', document); panel.id = id; @@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) { const collapse = element('button', document); collapse.className = 'panel-collapse-btn'; panel.append(toggle, collapse, element('button', document)); - document.body.append(panel); + const root = + id === 'data-panel' + ? 'left-panel-stack' + : ['location-bar', 'control-panel'].includes(id) + ? 'command-dock' + : 'right-context-rail'; + rails[root].append(panel); } const media = new EventTarget(); @@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) { } const shell = initMobileShell({ documentRef: document, + onTabOpened, matchMedia: () => media, MutationObserverImpl: FakeMutationObserver, ResizeObserverImpl: null, @@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => { assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(railSheet)); }); + +function touchEvent( + document, + type, + target, + { x = 10, y = 10, count = 1, id = 1, cancelable = true } = {}, +) { + const event = new Event(type, { cancelable }); + const touch = { identifier: id, clientX: x, clientY: y }; + Object.defineProperty(event, 'target', { value: target }); + event.touches = + type === 'touchend' || type === 'touchcancel' + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...touch, + identifier: id + i, + })); + event.changedTouches = [touch]; + document.dispatchEvent(event); + return event; +} + +test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => { + for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) { + const phone = fixture(); + const { document, shell, press, tab } = phone; + press(sheet); + const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]); + touchEvent(document, 'touchstart', target); + const move = touchEvent(document, 'touchmove', target, { y: 90 }); + assert.equal(move.defaultPrevented, true); + assert.equal(shell.getSheet(), sheet, 'waits until finger lifts'); + touchEvent(document, 'touchend', target, { y: 90 }); + assert.equal(shell.getSheet(), null); + assert.equal(phone.closedCount(), 1); + assert.equal(document.activeElement, tab(sheet)); + for (const id of MOBILE_SHEET_PANELS[sheet]) + assert.equal(phone.collapsed(id), true); + assert.ok( + phone.calls.every( + (call) => call.persist === false && call.syncShare === false, + ), + ); + shell.destroy(); + } +}); + +test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => { + for (const nested of [false, true]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + const scroller = nested + ? panel + : document.getElementById('left-panel-stack'); + scroller.scrollTop = 40; + touchEvent(document, 'touchstart', panel); + scroller.scrollTop = 0; + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('taps, short pulls, upward and horizontal gestures never dismiss', () => { + for (const path of [ + [], + [{ x: 12, y: 14 }], + [{ y: 40 }], + [{ y: -30 }, { y: 120 }], + [{ x: 60, y: 12 }, { y: 120 }], + ]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + for (const point of path) touchEvent(document, 'touchmove', panel, point); + touchEvent(document, 'touchend', panel, path.at(-1) ?? {}); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('native controls and custom sliders keep touch gestures', () => { + for (const tag of [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'slider', + 'editable', + ]) { + const { document, shell, press } = fixture(); + press('more'); + const control = document.createElement( + ['slider', 'editable'].includes(tag) ? 'div' : tag, + ); + if (tag === 'slider') control.setAttribute('role', 'slider'); + if (tag === 'editable') control.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + control.append(child); + document.getElementById('control-panel').append(control); + touchEvent(document, 'touchstart', child); + assert.equal( + touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', child, { y: 120 }); + assert.equal(shell.getSheet(), 'more'); + shell.destroy(); + } +}); + +test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => { + for (const cancel of [ + 'multitouch', + 'cancel', + 'sheet', + 'desktop', + 'noncancelable', + ]) { + const { document, shell, press, resize } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + if (cancel === 'multitouch') + touchEvent(document, 'touchstart', document.body, { count: 2 }); + if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel); + if (cancel === 'sheet') press('context'); + if (cancel === 'desktop') { + resize(false); + resize(true); + press('layers'); + } + if (cancel === 'noncancelable') + touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers'); + shell.destroy(); + } +}); + +test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => { + const { document, shell, press } = fixture(); + press('layers'); + const hiddenPanel = document.getElementById('global-context-panel'); + touchEvent(document, 'touchstart', hiddenPanel); + assert.equal( + touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', hiddenPanel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + shell.destroy(); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), null); +}); + +test('the tab-open callback reports only a successful manual opening', () => { + const opened = []; + const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) }); + phone.shell.setSheet('context'); + assert.deepEqual(opened, []); + phone.press('context'); + assert.deepEqual(opened, [], 'closing is not a new context request'); + phone.press('context'); + assert.deepEqual(opened, ['context']); + phone.setBodyClass('cockpit-mode'); + phone.press('context'); + assert.deepEqual( + opened, + ['context'], + 'blocked sheets cannot activate a mode', + ); + phone.shell.destroy(); +}); + +test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { + const calls = []; + const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; + const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const entity = viewer.trackedEntity; + const enabled = new Set(['flights', 'weather']); + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + syncDetection() {}, + scheduleLayout() {}, + showToast() {}, + }, + }); + controls.connect({ + layers: new Map( + ['flights', 'weather', 'military-awareness'].map((id) => [id, {}]), + ), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => ({}), + isEffectivelyEnabled: (id) => enabled.has(id), + isEnabled: (id) => enabled.has(id), + setLayerParams: (id, params) => calls.push({ id, params }), + setEnabled: async (id, value) => { + calls.push({ id, value }); + if (!value) enabled.delete(id); + if (id === 'flights' && !value) viewer.trackedEntity = null; + return true; + }, + _setEnabledWithIntent: (id, value) => { + calls.push({ id, value }); + enabled.add(id); + return { intentEpoch: 1, promise: Promise.resolve(true) }; + }, + }); + const shell = { + cockpitView: { + readAircraftInfo: () => (viewer.trackedEntity ? tracked : null), + }, + getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget, + getContextModeState: () => controls.getContextModeState(), + setContextMode: (mode) => controls.setContextMode(mode), + _showToast: () => assert.fail('successful entry must not report a failure'), + }; + try { + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + true, + ); + assert.equal(controls.getContextModeState().mode, 'flights'); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(shell.getAircraftTrackingTarget(), { + layerId: 'flights', + id: 'abc123', + }); + assert.ok(enabled.has('flights')); + assert.ok( + calls.some( + (call) => call.id === 'military-awareness' && call.value === true, + ), + ); + assert.ok(!calls.some((call) => call.id === 'flights')); + const count = calls.length; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + calls.length, + count, + 'an already active Contacts session is not restarted', + ); + } finally { + controls.stop(); + controls.disconnect(); + } +}); + +test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => { + for (const state of [ + { mode: 'space-missions' }, + { mode: 'flights' }, + { changing: true }, + { entering: 'space-missions' }, + { noTrack: true }, + { disposed: true }, + { sheet: 'layers' }, + ]) { + const shell = { + _disposed: state.disposed, + getContextModeState: () => state, + getAircraftTrackingTarget: () => + state.noTrack ? null : { layerId: 'flights', id: 'abc123' }, + setContextMode: () => + assert.fail('guarded tab must not mutate Context or tracking'), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call( + shell, + state.sheet || 'context', + ), + false, + ); + } +}); + +test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => { + for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) { + const notices = []; + const shell = { + getContextModeState: () => ({ mode: null }), + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }), + setContextMode: async () => { + if (outcome === 'throw') throw new Error('source unavailable'); + if (outcome === 'disposed') shell._disposed = true; + return { ok: false, cancelled: outcome === 'cancelled' }; + }, + _showToast: (message) => notices.push(message), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + notices.length, + ['failure', 'throw'].includes(outcome) ? 1 : 0, + ); + } +}); + +test('a slow downward pull is claimed before Android native scroll takes ownership', () => { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel, { y: 100 }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented, + false, + 'tap jitter is left native', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented, + true, + 'cancel the first directional move while Android still allows it', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented, + true, + ); + touchEvent(document, 'touchend', panel, { y: 175 }); + assert.equal(shell.getSheet(), null); + shell.destroy(); +}); diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 4cf32513a..aecfdf8d4 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { /* ── Top bar: logo left, globe actions right ───────────────────────────── */ body.mobile-shell #title-bar { - top: calc(var(--mobile-top-inset) + 12px); + top: calc(var(--mobile-top-inset) + 10px); left: 14px; } +body.mobile-shell #title-bar .title-logo { + width: 58px; +} + body.mobile-shell #title-bar h1 > :not(.title-logo), body.mobile-shell #title-bar .subtitle { display: none; From 9e2879c0ba5021b1127586ad3b4ab1dde6d730cc Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:52 -0400 Subject: [PATCH 17/25] fix(kartaview): reject impossible photo timestamps Co-authored-by: Cursor --- .../streetLevel/providers/kartaview/policy.js | 29 +++++++++++++++++-- .../providers/kartaview/policy.test.mjs | 4 +++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index b4533c1b5..76a0ea208 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) { String(value ?? ''), ); if (!match) return null; - const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; - if (Number(y) < 1990) return null; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4] ?? 0); + const minutes = Number(match[5] ?? 0); + const seconds = Number(match[6] ?? 0); + if (year < 1990) return null; + if ( + month < 1 || + month > 12 || + day < 1 || + hours > 23 || + minutes > 59 || + seconds > 59 + ) + return null; + // Date.UTC rolls 31 February into March. A photo would then pass or fail + // the day filter on the wrong calendar day, so an impossible date is unknown. + const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) + return null; return Number.isFinite(ms) ? ms : null; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 82a68555e..540071f0b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => kartaViewTimeMs('2022-03-21T06:42:59'), Date.UTC(2022, 2, 21, 6, 42, 59), ); + assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null); + assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null); + assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null); + assert.equal(kartaViewTimeMs('2022-04-31'), null); assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); assert.equal(kartaViewTimeMs(''), null); assert.equal(kartaViewTimeMs(null), null); From 7d2294580b23c2d5a413bb76296d5a01a5dd8831 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:55 -0400 Subject: [PATCH 18/25] fix(firms): keep impossible acquisition dates off the fire clock Co-authored-by: Cursor --- src/data/firmsAdapt.js | 43 +++++++++++++++++++++++++++--------- src/data/firmsAdapt.test.mjs | 40 +++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 312838e80..18d9fc110 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) { const cached = cache.get(key); if (cached !== undefined) return cached; + // Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February + // into March, and the fire's age would describe a detection that never happened. + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + cache.set(key, 0); + return 0; + } + const timeText = String(time ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) { + cache.set(key, 0); + return 0; + } + const hhmm = timeText.padStart(4, '0'); const year = Number(date.slice(0, 4)); const month = Number(date.slice(5, 7)); const day = Number(date.slice(8, 10)); - const hhmm = String(time ?? '0000').padStart(4, '0'); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); - const valid = - Number.isFinite(year) && - Number.isFinite(month) && - Number.isFinite(day) && - Number.isFinite(hours) && - Number.isFinite(minutes); - const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0; - cache.set(key, ms); - return ms; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hours > 23 || + minutes > 59 + ) { + cache.set(key, 0); + return 0; + } + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + const result = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : 0; + cache.set(key, result); + return result; } /** diff --git a/src/data/firmsAdapt.test.mjs b/src/data/firmsAdapt.test.mjs index 0e1e56dd6..fa5de9e69 100644 --- a/src/data/firmsAdapt.test.mjs +++ b/src/data/firmsAdapt.test.mjs @@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => { test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => { const cache = new Map(); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(cache.size, 1); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0); }); +test('impossible acquisition dates stay untimed instead of rolling forward', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ]) { + assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`); + const [fire] = adaptFirmsRecords([ + proxyRecord({ acqDate: date, acqTime: time }), + ]); + assert.equal(fire.acqMs, 0, `${date}:${time}`); + assert.equal(fire.lat, 38.99488); + } + const [leap] = adaptFirmsRecords([ + proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }), + ]); + assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45)); +}); + test('daynight "D" → night false', () => { const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]); assert.equal(fire.night, false); @@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () = proxyRecord({ lat: 40.1 }), ]); assert.equal(fires.length, 2); - assert.deepEqual(fires.map((f) => f.index), [0, 1]); + assert.deepEqual( + fires.map((f) => f.index), + [0, 1], + ); }); test('non-finite frp/brightness → 0; empty input → []', () => { - const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]); + const [fire] = adaptFirmsRecords([ + proxyRecord({ frp: 'n/a', brightness: undefined }), + ]); assert.equal(fire.frp, 0); assert.equal(fire.brightness, 0); assert.deepEqual(adaptFirmsRecords([]), []); From dbf306379f4dce0c1639a6572c8f966ca53bf776 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:04 -0400 Subject: [PATCH 19/25] changelog: note 1341-kartaview-impossible-timestamps Co-authored-by: Cursor --- changelog.d/1341-kartaview-impossible-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-impossible-timestamps.md diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md new file mode 100644 index 000000000..302ff38f4 --- /dev/null +++ b/changelog.d/1341-kartaview-impossible-timestamps.md @@ -0,0 +1,3 @@ +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). From bdb3851bb23e4b3aa76a2d9eba124eee38a39342 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:07 -0400 Subject: [PATCH 20/25] changelog: note 1342-firms-acquisition-calendar Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1342-firms-acquisition-calendar.md diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md new file mode 100644 index 000000000..c6b97d237 --- /dev/null +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -0,0 +1,3 @@ +- Keep an impossible wildfire acquisition date untimed, so 30 February is not + shown as a detection two days later + (Rudy Celekli, @rudycelekli, #1342). From cbefa2567a775effb275de55c14358580c95bb2f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:32 -0400 Subject: [PATCH 21/25] Add the changelog note for impossible KartaView timestamps. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md new file mode 100644 index 000000000..5ccb0d473 --- /dev/null +++ b/changelog.d/1341-kartaview-timestamps.md @@ -0,0 +1,3 @@ +- Keep an impossible KartaView photo date unknown instead of rolling it onto + a later day, so the capture time and the imagery filter stay on the day + the photo was taken (Rudy Celekli, @rudycelekli, #1341). From e07b0e9256d1d4f215f3bf1e7f43228a5cf111a4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:34 -0400 Subject: [PATCH 22/25] Add the changelog note for impossible wildfire acquisition dates. Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md index c6b97d237..e0003636d 100644 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -1,3 +1,3 @@ -- Keep an impossible wildfire acquisition date untimed, so 30 February is not - shown as a detection two days later +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen (Rudy Celekli, @rudycelekli, #1342). From d9ecd9869aa00ca98b3a7006590bf8a45f70bf97 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:14:35 -0400 Subject: [PATCH 23/25] Drop the second changelog fragment for the KartaView fix. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md deleted file mode 100644 index 5ccb0d473..000000000 --- a/changelog.d/1341-kartaview-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible KartaView photo date unknown instead of rolling it onto - a later day, so the capture time and the imagery filter stay on the day - the photo was taken (Rudy Celekli, @rudycelekli, #1341). From 07318cc9a4bed2b1f5b67fb0d80dc2e964572743 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:19:21 -0700 Subject: [PATCH 24/25] fix(geo): wrap longitude differences across the antimeridian in point distances (#1304) Wrap longitude differences in annotation proximity checks and nearby-place distances so points across the antimeridian remain close. Original contribution by quanticsoul4772. Co-authored-by: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> --- changelog.d/1304-antimeridian-distances.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/data/placeProviderPayloads.js | 5 ++++- src/sources/featureGeometry.js | 5 ++++- src/sources/featureGeometry.test.mjs | 15 +++++++++++++++ src/tooling/placeProviders.test.mjs | 18 ++++++++++++++++++ 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1304-antimeridian-distances.md create mode 100644 src/sources/featureGeometry.test.mjs diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md new file mode 100644 index 000000000..fc7744f86 --- /dev/null +++ b/changelog.d/1304-antimeridian-distances.md @@ -0,0 +1,2 @@ +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..ab0d18b77 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5515,6 +5515,11 @@ easier to meet (detection is now on more often), but does not create it. short-way-round longitude span and wraps the padded edges, so a dateline- crossing box stays its true width. Raw subtraction inflated a 0.41° metro box to 86.7° and a 60° territory to 132°. +- **Point distances take the short way round 180°.** The annotation + resolver's `approximateDistanceM` (voice "point out" proximity checks) and + `places_nearby` distances wrap the longitude difference. Raw subtraction put + Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check + refused it, and sorted a place 2 km across 180° last. - CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild; the store was wiped clean, no import from the old `v1` key). - CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js index 37fd99ef7..8adf935a9 100644 --- a/src/data/placeProviderPayloads.js +++ b/src/data/placeProviderPayloads.js @@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) { return Number.MAX_SAFE_INTEGER; const latitudeScale = 111320; const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); + // The short way round, so places either side of 180° stay close. + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); return Math.round( - Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale), + Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale), ); } diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index a18d5f22f..b2ccc9630 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -250,10 +250,13 @@ export function approximateAreaM2(coords) { return Math.abs(area) / 2; } +/** Local equirectangular distance in metres, the short way round 180°. */ export function approximateDistanceM(latA, lonA, latB, lonB) { const latScale = 111_320; const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); + return Math.hypot((latB - latA) * latScale, dLon * lonScale); } export function pointInPolygon(lon, lat, coords) { diff --git a/src/sources/featureGeometry.test.mjs b/src/sources/featureGeometry.test.mjs new file mode 100644 index 000000000..6da3ff92f --- /dev/null +++ b/src/sources/featureGeometry.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { approximateDistanceM } from './featureGeometry.js'; + +test('approximate distance takes the short way round 180°', () => { + // Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart. + const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33); + assert.ok(across > 150_000 && across < 200_000, String(across)); + assert.equal( + Math.round(approximateDistanceM(0, 179.9, 0, -179.9)), + Math.round(approximateDistanceM(0, -0.1, 0, 0.1)), + ); + // Away from 180° nothing changes. + assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320); +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 352cc8824..d533b7fb9 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); }); +test('nearby place distances take the short way round 180°', () => { + const at = (name, longitude) => ({ + id: name, + displayName: { text: name }, + location: { latitude: -16.8, longitude }, + types: ['point_of_interest'], + }); + // From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side. + const result = projectNearbyPlaces( + { places: [at('West', 179.942), at('East', -179.999)] }, + -16.8, + 179.98, + ); + const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM])); + assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East)); + assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West)); +}); + test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { const viewport = { low: { latitude: 1, longitude: 2 }, From 1c234ce0271d72d1e2fe93b214da8ce373b41203 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:25:42 -0700 Subject: [PATCH 25/25] fix(opensky): load OAuth credential files and retire Basic auth (#1229) Read OpenSky OAuth credentials from OPENSKY_CREDENTIALS_FILE and stop sending retired Basic authentication. Update setup diagnostics and guidance. Keep credential-file warnings free of parser excerpts and filesystem error details, cover both failure paths with a regression test, and use a launcher-compatible absolute path example. Original contribution by quanticsoul4772, with credential-warning and documentation follow-up by Sameh Khamis. Co-authored-by: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Co-authored-by: Sameh Khamis --- .env.example | 7 +- changelog.d/1229-opensky-oauth-only.md | 8 ++ docs/CURRENT-STATE.md | 4 + docs/opensky-auth.md | 24 ++-- scripts/setup-doctor.mjs | 25 +++- server/providers/aircraft/opensky.js | 131 ++++++++++-------- .../aircraft/openskyCredentials.test.mjs | 81 +++++++++++ src/setupDoctor.test.mjs | 41 +++++- src/sources/live/standalone.js | 4 +- src/tooling/liveProviders.test.mjs | 63 +++++++++ 10 files changed, 309 insertions(+), 79 deletions(-) create mode 100644 changelog.d/1229-opensky-oauth-only.md create mode 100644 server/providers/aircraft/openskyCredentials.test.mjs diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab0d18b77..dbd591b17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1916,6 +1916,10 @@ Updated: September 23, 2026 ## Aircraft and vessel server modules +OpenSky credential-file failures emit a fixed warning once per process; neither +JSON parser excerpts nor filesystem error messages are logged. The environment +example uses an absolute path compatible with the macOS launchers. + Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky state vectors and regional fallback, adsb.lol military positions, ADSBDB enrichment and track backfill. Vessel routes and websocket/watchdog setup live diff --git a/docs/opensky-auth.md b/docs/opensky-auth.md index 67844f912..c5f0a387b 100644 --- a/docs/opensky-auth.md +++ b/docs/opensky-auth.md @@ -3,10 +3,11 @@ God's Eye View uses explicit auth modes for `/api/flights`: - `OPENSKY_AUTH_MODE=oauth` (default, recommended) -- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) -- `OPENSKY_AUTH_MODE=basic` +- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous) - `OPENSKY_AUTH_MODE=anon` +`OPENSKY_AUTH_MODE=basic` is retired (see below). + Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow. ## Quick Start (OAuth Client JSON) @@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order: 2. `OPENSKY_CREDENTIALS_FILE` 3. Keychain (`opensky-network` / `client_id`, `client_secret`) -## Basic Auth (Legacy) +The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and +Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any +platform. It reads the file on each token refresh. -Basic auth is still supported for now, but OpenSky is moving away from it. +## Basic Auth (Retired) -```bash -OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh -``` +OpenSky stopped accepting username/password (HTTP Basic) sign-in on +2026-03-18; its API now takes only OAuth2 client credentials. With +`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` / +`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and +logs once what to set instead. Create an API client on your OpenSky +account page and use its `credentials.json` as above. ## Troubleshooting @@ -61,6 +67,6 @@ Useful reasons: - `oauth_invalid_or_missing`: OAuth client not found/usable - `oauth_invalid_credentials`: OAuth client rejected by OpenSky -- `basic_invalid_credentials`: username/password rejected -- `missing_basic_creds`: basic mode missing user/pass +- `missing_oauth_creds`: auto mode found no OAuth client +- `basic_retired`: basic mode requested; answered anonymously - `rate_limited`: OpenSky throttling diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 01e048091..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -153,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -164,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index a69885e2f..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, @@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -174,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -524,21 +574,21 @@ export function openSkyProxy() { _openskySnapshotInFlight, 'global', async () => { - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); const headers = { Accept: 'application/json' }; let usedMode = 'anon'; let reason = 'forced_anonymous'; if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; } + reason = 'basic_retired'; } else if (requestedMode === 'oauth') { const token = await getOpenSkyToken(); if (token) { @@ -554,31 +604,12 @@ export function openSkyProxy() { headers.Authorization = `Bearer ${token}`; usedMode = 'oauth'; reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; } else { - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) @@ -586,12 +617,6 @@ export function openSkyProxy() { // Refine the reason string to reflect the actual outcome if (upstream.ok && reason === 'forced_anonymous') { reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; } else if ( upstream.ok && usedMode === 'oauth' && @@ -627,12 +652,11 @@ export function openSkyProxy() { body, usedMode, reason, - hasBasicCreds, sourceEpochMs, }; }, ); - let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + let { upstream, body, usedMode, reason, sourceEpochMs } = await refresh.promise; if ( upstream.ok && @@ -684,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 3f2c73b3b..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -31,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 6b880d186..1f1befc95 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'basic', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return Response.json({ time: Math.floor(Date.now() / 1000), states: [] }); + } + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?basic=${Math.random()}` + ); + const flights = install(fresh.openSkyProxy()); + const first = await flights('/api/flights'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-opensky-auth'], 'anon'); + assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired'); + assert.deepEqual(authorizations, [undefined]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /retired username\/password sign-in/); + assert.ok(!warnings[0].includes('fixture-password')); +}); + +test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'auto', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/token')) + return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return new Response('', { status: 401 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?auto=${Math.random()}` + ); + await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97'); + assert.deepEqual(authorizations, ['Bearer fixture-token']); +}); + test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { t.mock.method(globalThis, 'fetch', async () => { assert.fail('retired history must not contact the upstream');