From 71797427d619df1938372cc1e25dcc92640e4ce4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 19:01:58 +0400 Subject: [PATCH] Street Level: a posted view whose camera was taken stands down whole again Applying the rest of a posted view after the camera was taken during its terrain wait still let it change the camera: stop_tracking dropped a follow the user had just started, and annotate_map framed the view's marks. A camera taken during the wait now ends the view again, as before. The 2 s terrain timeout and the per-view catch on the queue stay. --- docs/CURRENT-STATE.md | 3 +- src/app/embed.js | 32 +++---- src/app/embedTerrainTakeover.test.mjs | 118 ++++++++++++++++++++++++++ src/streetLevelReplacement.test.mjs | 5 -- 4 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 src/app/embedTerrainTakeover.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9585de17..943458e5e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5324,8 +5324,7 @@ another map; `street_level` does not combine with `follow`. point, measure the view's altitude from the terrain under the camera (`groundedLookAt` with the terrain heights service) before taking the look-at point, and keep the view's own estimate without a terrain answer (the embedded -app waits 2 s for one). A camera taken during that wait stays where it is: -the rest of the view applies and its photos do not open. Links carry +app waits 2 s for one). Links carry `sl=,,`; the globe panel applies views through the action, and a plain link runs it once restored (one shot: the rewritten hash drops `sl`). A link that follows an entity ignores `sl`. diff --git a/src/app/embed.js b/src/app/embed.js index 5a3119da9..85c4f0752 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -500,7 +500,6 @@ export function installViews({ queue = queue .then(async () => { if (signal?.aborted) return; - let apply = run; if (unplaced && view.street_level) { // A terrain service that does not answer soon leaves the view's // own estimate. @@ -514,20 +513,21 @@ export function installViews({ .catch(() => null) .finally(() => owner.stop()); if (signal?.aborted) return; - // Whoever takes the camera meanwhile (a drag, a search, the - // assistant) keeps it: the photos stand down, as when the camera - // is taken during the action, and the rest of the view applies. - if (owner.taken()) - apply = (name, ...rest) => - name === 'show_street_level' - ? { - ok: false, - cancelled: true, - superseded: true, - error: 'The camera moved on before the photo opened', - } - : run(name, ...rest); - else if (point) + // Whoever takes the camera while the terrain answers (a drag, a + // search, the assistant) keeps it: the view stands down. + if (owner.taken()) { + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + steps: [{ step: 'street_level', ok: false, superseded: true }], + }, + replyOrigin, + ); + return; + } + if (point) view = createView({ ...event.data.view, street_level: { ...asked, ...point }, @@ -536,7 +536,7 @@ export function installViews({ const steps = await applyView(view, { viewer, dataManager, - run: apply, + run, signal, autoOpen, }); diff --git a/src/app/embedTerrainTakeover.test.mjs b/src/app/embedTerrainTakeover.test.mjs new file mode 100644 index 000000000..60ead319d --- /dev/null +++ b/src/app/embedTerrainTakeover.test.mjs @@ -0,0 +1,118 @@ +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installViews } from './embed.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; +import { createAnnotationEngine } from '../annotations/annotationEngine.js'; +import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs'; + +// A camera taken while a posted view waits on terrain stays with whoever took +// it: the stale view stands down whole, so it neither stops a newer follow nor +// frames its own marks. Offline: real installViews, action runner and +// annotation engine; the viewer, renderer, flights data and terrain are fixtures. +globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); }; +globalThis.requestAnimationFrame = () => 1; +globalThis.cancelAnimationFrame = () => {}; +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function scenario(t, effect) { + document.body.className = ''; + const entered = deferred(); + const terrain = deferred(); + const viewer = fakeCesiumViewer(); + viewer.clock = { onTick: new Cesium.Event() }; + viewer.canvas = viewer.scene.canvas; + // A real perspective frustum looking down at 0,0; the old view's mark + // at 48N,2E is outside it. No override of the engine's visibility logic. + Object.assign(viewer.camera, { + position: Cesium.Cartesian3.fromDegrees(0, 0, 1000), + direction: new Cesium.Cartesian3(-1, 0, 0), + up: new Cesium.Cartesian3(0, 0, 1), + heading: 0, + frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }), + }); + const entity = { id: 'fixture-aircraft' }; + const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 }; + let stopCalls = 0; + const flights = { + findByQuery: () => aircraft, + trackById: () => { viewer.trackedEntity = entity; return true; }, + getTrackedInfo: () => viewer.trackedEntity ? aircraft : null, + stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; }, + }; + const dataManager = { + layers: new Map([['flights', { module: flights }]]), + getAll: () => [{ id: 'flights', enabled: true }], + isEnabled: id => id === 'flights', + setLayerParams: () => true, + }; + const shell = { + _navigationGeneration: 1, + initialRestorePromise: Promise.resolve(), + setCleanView() {}, + runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); }, + }; + const annotations = createAnnotationEngine({ viewer, + renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} }, + }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations }); + const calls = []; + const run = async (name, args, options) => { + calls.push(name); + return runner(name, args, options); + }; + const replies = []; + const peer = { postMessage: message => replies.push(message) }; + const windowRef = new EventTarget(); + windowRef.parent = peer; + windowRef.document = document; + const remove = installViews({ shell, viewer, dataManager, run, windowRef, + location: { search: '?embed=1', hash: '' }, + terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } }, + }); + t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; }); + await tick(); + const event = new Event('message'); + Object.assign(event, { source: peer, origin: 'https://host.example', data: { + type: 'gev:view', id: 1, view: { + camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, + layers: ['flights'], + street_level: { provider: 'mapillary' }, + annotations: effect === 'annotations' + ? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [], + }, + } }); + windowRef.dispatchEvent(event); + await entered.promise; + if (effect === 'follow') { + const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' }); + assert.equal(follow.ok, true, 'new follow starts through the real runner'); + assert.equal(viewer.trackedEntity, entity); + } else { + viewer.canvas.dispatchEvent(new Event('pointerdown')); + } + terrain.resolve([{ ellipsoid: 200 }]); + for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick(); + const reply = replies.find(reply => reply.id === 1); + t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity, + annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply })); + assert.ok(reply, 'host receives completion'); + assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied'); + assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler'); + if (effect === 'follow') { + assert.equal(stopCalls, 0, 'stale view must not stop the newer follow'); + assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected'); + } else { + assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera'); + } +} + +for (const effect of ['follow', 'annotations']) { + test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect)); +} diff --git a/src/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs index 84fe3cf30..f0aa97ec0 100644 --- a/src/streetLevelReplacement.test.mjs +++ b/src/streetLevelReplacement.test.mjs @@ -144,11 +144,6 @@ for (const takeover of ['navigation', 'gesture', 'none']) { assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera'); assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0); assert.equal(reply.ok, takeover === 'none'); - // Only the photos stand down: the rest of the view still applies, and - // the camera stays with whoever took it. - const steps = reply.steps.map(step => step.step); - assert.ok(steps.includes('clear_annotations'), JSON.stringify(steps)); - assert.ok(!steps.includes('camera'), JSON.stringify(steps)); }); }