From 4c9b27dc204d52603942be7803e2432d2e8b1b0e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:48:07 +0530 Subject: [PATCH] Keep a settled flight in reach of a later failed lookup A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing. --- src/voice/gevActions.js | 22 +++++++------ src/voice/gevActions.test.mjs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 83cdea367..089d95be0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1144,11 +1144,14 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos: a grab during the earlier one counts. The flight - // replaced is the last one that moved, past lookups that found nothing. + // Chained fly-tos: a grab during an earlier one still flying counts. + // The flight replaced is the last one that moved, past lookups that + // found nothing. const prior = voiceFlightTarget(voiceFlight); - const replaced = prior && !prior.done ? prior : null; - const flight = trackVoiceFlight(replaced?.inputCount, replaced); + const flight = trackVoiceFlight( + prior && !prior.done ? prior.inputCount : undefined, + prior, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -2961,10 +2964,10 @@ let voiceFlightCount = 0; /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. - * `replaced` is the last flight that moved, if still pending when this one - * began (past lookups that found nothing). It is kept only - * while this one may turn out to have moved nothing (a lookup that found no - * place), so a queued motion can look past it. + * `replaced` is the last flight that moved when this one began (past lookups + * that found nothing), landed or not. It is kept only while this one may turn + * out to have moved nothing (a lookup that found no place), so a queued + * motion can look past it to that flight and its outcome. */ function trackVoiceFlight( inputCount = manualCameraInputCount(), @@ -2996,7 +2999,8 @@ function trackVoiceFlight( * actually started. A lookup that found nothing moved nothing, so look past * it to the flight it replaced, but never to one older than `start` (a flight * from an earlier reply). Null when every later lookup found nothing. With - * no `start`, the latest flight that started, however old. + * no `start`, the latest flight that started, back through lookups that + * found nothing. */ function voiceFlightTarget(latest, start = null) { let flight = latest; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ece2f92e0..678a13659 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -791,6 +791,65 @@ test('two lookups that found nothing in a row still leave the motion waiting for assert.equal((await moved).ok, true); }); +// A flight that already settled is still what a later failed lookup replaced. +test('a motion acts at a landed flight that a later failed lookup replaced', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].complete(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B's start made A's lookup stale; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion refuses when the flight a later failed lookup replaced was cancelled', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The motion sees Austin land, then follows A's lookup. + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[1].cancel(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({