mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Drop a queued voice motion when another navigation takes the camera mid-flight
A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting. The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does.
This commit is contained in:
1 parent
c4910d24d0
commit
1eef14454a
3 files changed
+113
-6
No files matched your search
@@ -296,6 +296,16 @@ export function createGevActionRunner({
|
||||
let voiceFlight = null;
|
||||
// Waiters to tell when a newer voice flight begins.
|
||||
const voiceFlightWatchers = new Set();
|
||||
// Camera handoffs (navigation stamps) seen, and how many of them voice
|
||||
// fly-tos made. A search, share link or import that stamps during a flight
|
||||
// leaves it flying; the motion queued behind it then yields to that one.
|
||||
let cameraHandoffs = 0;
|
||||
let voiceFlightHandoffs = 0;
|
||||
const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs;
|
||||
const unsubscribeCameraHandoff =
|
||||
styleManager?.subscribeCameraHandoff?.(() => {
|
||||
cameraHandoffs += 1;
|
||||
}) ?? null;
|
||||
let analystEngine;
|
||||
// Area handles live for this runner (the page): resolve_area, the
|
||||
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
|
||||
@@ -1155,6 +1165,8 @@ export function createGevActionRunner({
|
||||
prior && !prior.done ? prior.inputCount : undefined,
|
||||
prior,
|
||||
);
|
||||
// Navigation by anything but a voice fly-to since this one began.
|
||||
flight.handoffs = otherHandoffs();
|
||||
voiceFlight = flight;
|
||||
for (const notify of [...voiceFlightWatchers]) notify();
|
||||
// A motion already running (one the model sent before this fly-to) is
|
||||
@@ -1164,9 +1176,13 @@ export function createGevActionRunner({
|
||||
(hook) =>
|
||||
(...hookArgs) => {
|
||||
const before = getActiveCameraMotion();
|
||||
const handoffsBefore = cameraHandoffs;
|
||||
try {
|
||||
return hook(...hookArgs);
|
||||
} finally {
|
||||
// A voice fly-to's own stamps are not another navigation, even
|
||||
// for the flight it replaces (a later lookup may find nothing).
|
||||
voiceFlightHandoffs += cameraHandoffs - handoffsBefore;
|
||||
if (
|
||||
before &&
|
||||
!stoppedMotion &&
|
||||
@@ -1383,6 +1399,15 @@ export function createGevActionRunner({
|
||||
motion,
|
||||
error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`,
|
||||
};
|
||||
// A search, share link or import that took navigation during the
|
||||
// flight leaves it flying; running now would cancel that navigation.
|
||||
if (otherHandoffs() !== flight.handoffs)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
error: `Not run: another navigation took the camera during the flight, so the ${motion} was dropped.`,
|
||||
};
|
||||
}
|
||||
return moveCamera(args, (navigate, releaseOptions) =>
|
||||
runManagedVoiceNavigation(
|
||||
@@ -1712,6 +1737,7 @@ export function createGevActionRunner({
|
||||
if (disposed) return;
|
||||
runVoiceAction.resetConversation();
|
||||
disposed = true;
|
||||
unsubscribeCameraHandoff?.();
|
||||
voiceAreas.dispose();
|
||||
};
|
||||
return runVoiceAction;
|
||||
|
||||
@@ -143,6 +143,14 @@ test('track_entity runner narrates a callsign-less aircraft by its registration'
|
||||
function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
||||
const order = [];
|
||||
let generation = 0;
|
||||
// Camera handoffs, published on every navigation stamp as the shell does.
|
||||
const handoffListeners = new Set();
|
||||
const stamp = (noun) => {
|
||||
generation += 1;
|
||||
order.push(`stamp:${noun}`);
|
||||
for (const listener of [...handoffListeners]) listener({ generation });
|
||||
return generation;
|
||||
};
|
||||
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||
const viewer = {
|
||||
trackedEntity: { id: 'prior-aircraft' },
|
||||
@@ -179,11 +187,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
||||
return runExplicitNavigation({
|
||||
cockpitActive,
|
||||
noun,
|
||||
stamp: () => {
|
||||
generation += 1;
|
||||
order.push(`stamp:${noun}`);
|
||||
return generation;
|
||||
},
|
||||
stamp: () => stamp(noun),
|
||||
release: () => {
|
||||
order.push('release');
|
||||
viewer.trackedEntity = undefined;
|
||||
@@ -195,12 +199,21 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
|
||||
});
|
||||
},
|
||||
getDetectionState: () => ({ detectionMode: 'DENSE' }),
|
||||
subscribeCameraHandoff(listener) {
|
||||
handoffListeners.add(listener);
|
||||
return () => handoffListeners.delete(listener);
|
||||
},
|
||||
// A voice lookup stamps when it begins and leaves any flight flying.
|
||||
beginDeferredLocationNavigation: () => stamp('location-lookup'),
|
||||
reassertDeferredLocationNavigation: (stamped) => stamped === generation,
|
||||
};
|
||||
return {
|
||||
order,
|
||||
viewer,
|
||||
styleManager,
|
||||
currentGeneration: () => generation,
|
||||
// A location-panel search: a deferred stamp that leaves a flight flying.
|
||||
panelSearch: () => stamp('panel-search'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,6 +934,74 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a
|
||||
assert.equal(result.motion, 'orbit');
|
||||
});
|
||||
|
||||
// "Fly to Paris and orbit it", then a location-panel search while the camera
|
||||
// flies: the search stamps a deferred navigation without stopping the flight.
|
||||
// Run where the flight landed, the orbit took navigation and the search's
|
||||
// reassert failed, so the search was silently dropped.
|
||||
test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager, flights, panelSearch, currentGeneration } =
|
||||
tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
|
||||
const moved = runner('move_camera', { motion: 'orbit' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const search = panelSearch();
|
||||
flights[0].complete();
|
||||
const result = await moved;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Not run: another navigation took the camera/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(currentGeneration(), search, 'the search still owns navigation');
|
||||
});
|
||||
|
||||
test('a motion behind a geocoded fly-to yields to a search started while it flies', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager, panelSearch } = 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 fly = runner('fly_to_location', { query: 'Place A' });
|
||||
const moved = runner('move_camera', { motion: 'orbit' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
searches[0].resolve({ label: 'Place A', navigationMode: 'city-overview' });
|
||||
assert.equal((await fly).ok, true);
|
||||
panelSearch();
|
||||
searches[0].options.onComplete();
|
||||
const result = await moved;
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /another navigation took the camera/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
test('a search before the fly-to does not drop the motion queued behind it', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager, flights, panelSearch } = tiltAfterFlightHarness();
|
||||
const runner = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager,
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
panelSearch();
|
||||
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
|
||||
const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
flights[0].complete();
|
||||
const result = await moved;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.motion, 'tilt');
|
||||
});
|
||||
|
||||
test('a fly-to that stops a running motion says so', async (t) => {
|
||||
t.after(() => interruptCameraMotion('test-cleanup'));
|
||||
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||
|
||||
Reference in new issue
Block a user