mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Hold a voice tilt until its fly-to lands
"Fly to London and tilt the camera" reaches the app as two sibling tool calls, and OpenAI runs them concurrently. The tilt's navigation step cancelled the flight already in the air, so the camera tilted where it was; when the place still needed a lookup, the late flight replaced the tilt and London came out top-down. The voice runner now tracks its latest flight. A tilt sent while that flight is still looking up or flying waits for it to land (up to 30 s), then tilts at the destination. If the flight is cancelled, finds nothing or times out, the tilt is not run and says so.
This commit is contained in:
1 parent
69152868e4
commit
91a3f787d4
3 files changed
+207
-8
No files matched your search
@@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
|
> 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.
|
> `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. `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. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. 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.
|
- **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. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the tilt is not run and says so. A tilt sent before its fly-to is not reordered. 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.
|
- **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.
|
- **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).
|
- 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).
|
||||||
|
|||||||
+83
-7
@@ -276,6 +276,10 @@ export function createGevActionRunner({
|
|||||||
? { ...SPEECH_BUILDERS, ...speechBuilders }
|
? { ...SPEECH_BUILDERS, ...speechBuilders }
|
||||||
: SPEECH_BUILDERS;
|
: SPEECH_BUILDERS;
|
||||||
const _layerEnabledAt = new Map();
|
const _layerEnabledAt = new Map();
|
||||||
|
// The latest voice flight. A tilt in the same reply as "fly to X" waits for
|
||||||
|
// it to land: run at once, its navigation cancelled the flight, or the
|
||||||
|
// flight (still geocoding) then replaced the tilt.
|
||||||
|
let voiceFlight = null;
|
||||||
let analystEngine;
|
let analystEngine;
|
||||||
const resolveRegionRing = (name, signal) =>
|
const resolveRegionRing = (name, signal) =>
|
||||||
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
||||||
@@ -1103,7 +1107,10 @@ export function createGevActionRunner({
|
|||||||
runOptions,
|
runOptions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return flyToRequestedLocation(viewer, args, {
|
const flight = trackVoiceFlight();
|
||||||
|
voiceFlight = flight;
|
||||||
|
const flown = flyToRequestedLocation(viewer, args, {
|
||||||
|
onFlightEnd: flight.settle,
|
||||||
onProgress: reportProgress,
|
onProgress: reportProgress,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
searchNavigation,
|
searchNavigation,
|
||||||
@@ -1131,6 +1138,14 @@ export function createGevActionRunner({
|
|||||||
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager);
|
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// No flight started (not found, refused, cancelled): nothing to wait for.
|
||||||
|
flown.then(
|
||||||
|
(result) => {
|
||||||
|
if (result?.ok !== true) flight.settle('failed');
|
||||||
|
},
|
||||||
|
() => flight.settle('failed'),
|
||||||
|
);
|
||||||
|
return flown;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'adjust_camera_zoom') {
|
if (name === 'adjust_camera_zoom') {
|
||||||
@@ -1173,6 +1188,23 @@ export function createGevActionRunner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'move_camera') {
|
if (name === 'move_camera') {
|
||||||
|
if (
|
||||||
|
String(args.motion || '').toLowerCase() === 'tilt' &&
|
||||||
|
voiceFlight &&
|
||||||
|
!voiceFlight.done
|
||||||
|
) {
|
||||||
|
const status = await waitForVoiceFlight(voiceFlight, runOptions.signal);
|
||||||
|
if (!current())
|
||||||
|
return { ok: false, action: 'move_camera', cancelled: true };
|
||||||
|
if (status !== 'arrived')
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
action: 'move_camera',
|
||||||
|
motion: 'tilt',
|
||||||
|
error:
|
||||||
|
'Not run: the flight this tilt was waiting for did not arrive.',
|
||||||
|
};
|
||||||
|
}
|
||||||
return moveCamera(args, (navigate, releaseOptions) =>
|
return moveCamera(args, (navigate, releaseOptions) =>
|
||||||
runManagedVoiceNavigation(
|
runManagedVoiceNavigation(
|
||||||
styleManager,
|
styleManager,
|
||||||
@@ -2690,6 +2722,42 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run one validated voice camera mutation through the UI-owned authority seam. */
|
/** Run one validated voice camera mutation through the UI-owned authority seam. */
|
||||||
|
/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */
|
||||||
|
function trackVoiceFlight() {
|
||||||
|
let resolve;
|
||||||
|
const flight = {
|
||||||
|
done: false,
|
||||||
|
ended: new Promise((settle) => {
|
||||||
|
resolve = settle;
|
||||||
|
}),
|
||||||
|
settle: (status) => {
|
||||||
|
if (flight.done) return;
|
||||||
|
flight.done = true;
|
||||||
|
resolve(status);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return flight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */
|
||||||
|
const VOICE_FLIGHT_WAIT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */
|
||||||
|
function waitForVoiceFlight(flight, signal) {
|
||||||
|
if (signal?.aborted) return Promise.resolve('aborted');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = (status) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener?.('abort', onAbort);
|
||||||
|
resolve(status);
|
||||||
|
};
|
||||||
|
const onAbort = () => finish('aborted');
|
||||||
|
const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS);
|
||||||
|
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||||
|
flight.ended.then(finish);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function runManagedVoiceNavigation(
|
function runManagedVoiceNavigation(
|
||||||
styleManager,
|
styleManager,
|
||||||
noun,
|
noun,
|
||||||
@@ -3252,6 +3320,7 @@ async function flyToRequestedLocation(
|
|||||||
runImmediate = null,
|
runImmediate = null,
|
||||||
beginDeferred = null,
|
beginDeferred = null,
|
||||||
reassertDeferred = null,
|
reassertDeferred = null,
|
||||||
|
onFlightEnd = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const requestedRangeM = Number(args.rangeM);
|
const requestedRangeM = Number(args.rangeM);
|
||||||
@@ -3275,12 +3344,19 @@ async function flyToRequestedLocation(
|
|||||||
settleArrival = resolve;
|
settleArrival = resolve;
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const arrivalHooks = arrival
|
const arrivalHooks =
|
||||||
? {
|
arrival || onFlightEnd
|
||||||
onComplete: () => settleArrival?.('arrived'),
|
? {
|
||||||
onCancel: () => settleArrival?.('cancelled'),
|
onComplete: () => {
|
||||||
}
|
settleArrival?.('arrived');
|
||||||
: {};
|
onFlightEnd?.('arrived');
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
settleArrival?.('cancelled');
|
||||||
|
onFlightEnd?.('cancelled');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {};
|
||||||
const afterArrival = async (result, label) => {
|
const afterArrival = async (result, label) => {
|
||||||
if (!arrival || result?.ok !== true) return result;
|
if (!arrival || result?.ok !== true) return result;
|
||||||
onProgress('fly', label);
|
onProgress('fly', label);
|
||||||
|
|||||||
@@ -217,6 +217,129 @@ test('dependent voice navigation waits for the destination viewport to arrive',
|
|||||||
assert.equal(result.arrived, true);
|
assert.equal(result.arrived, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "fly to London and tilt the camera" arrives as two sibling calls.
|
||||||
|
// Run at once, the tilt's navigation cancelled the flight; with a geocoded
|
||||||
|
// place, the late flight replaced the tilt.
|
||||||
|
function tiltAfterFlightHarness() {
|
||||||
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
|
const harness = createVoiceNavigationHarness();
|
||||||
|
const { viewer, styleManager } = harness;
|
||||||
|
const flights = [];
|
||||||
|
viewer.camera.flyTo = (options) => {
|
||||||
|
harness.order.push('fly');
|
||||||
|
flights.push(options);
|
||||||
|
};
|
||||||
|
styleManager.runImmediateLocationNavigation = (navigate) => (
|
||||||
|
styleManager.runImmediateNavigation('location', navigate)
|
||||||
|
);
|
||||||
|
return { ...harness, flights };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settledYet = (promise) => {
|
||||||
|
let settled = false;
|
||||||
|
promise.then(() => { settled = true; });
|
||||||
|
return () => settled;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a tilt in the same reply as a fly-to waits for the flight to land', async () => {
|
||||||
|
const { viewer, styleManager, order, flights } = tiltAfterFlightHarness();
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager: { layers: new Map(), getAll: () => [] },
|
||||||
|
});
|
||||||
|
const fly = await runner('fly_to_location', { locationId: 'austin' });
|
||||||
|
assert.equal(fly.ok, true);
|
||||||
|
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||||
|
const tiltDone = settledYet(tilt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tiltDone(), false, 'the tilt must wait while the camera flies');
|
||||||
|
assert.equal(
|
||||||
|
order.slice(order.indexOf('fly')).includes('cancel'),
|
||||||
|
false,
|
||||||
|
'the tilt must not cancel the flight',
|
||||||
|
);
|
||||||
|
flights[0].complete();
|
||||||
|
const result = await tilt;
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.motion, 'tilt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tilt waits through a geocoded fly-to search, then for its flight', async () => {
|
||||||
|
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||||
|
let finishSearch;
|
||||||
|
let landFlight;
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager: { layers: new Map(), getAll: () => [] },
|
||||||
|
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
|
||||||
|
finishSearch = () => {
|
||||||
|
landFlight = options.onComplete;
|
||||||
|
resolve({ label: query, navigationMode: 'city-overview' });
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const fly = runner('fly_to_location', { query: 'Somewhere Far' });
|
||||||
|
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||||
|
const tiltDone = settledYet(tilt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tiltDone(), false, 'the tilt must wait for the place lookup');
|
||||||
|
finishSearch();
|
||||||
|
assert.equal((await fly).ok, true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tiltDone(), false, 'the tilt must wait for the flight too');
|
||||||
|
landFlight();
|
||||||
|
assert.equal((await tilt).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tilt waiting on a flight that never lands is not run', async () => {
|
||||||
|
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager: { layers: new Map(), getAll: () => [] },
|
||||||
|
});
|
||||||
|
await runner('fly_to_location', { locationId: 'austin' });
|
||||||
|
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||||
|
flights[0].cancel();
|
||||||
|
const result = await tilt;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /did not arrive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tilt beside a fly-to that found nothing is not run', async () => {
|
||||||
|
const { viewer, styleManager } = tiltAfterFlightHarness();
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager: { layers: new Map(), getAll: () => [] },
|
||||||
|
searchNavigation: async () => null,
|
||||||
|
});
|
||||||
|
const fly = runner('fly_to_location', { query: 'Nowhere' });
|
||||||
|
const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||||
|
assert.equal((await fly).ok, false);
|
||||||
|
// Nothing flew: the tilt answers at once and honestly, never at the old view.
|
||||||
|
const result = await tilt;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.error, /did not arrive/);
|
||||||
|
// A later, separate tilt is not held by the failed lookup.
|
||||||
|
assert.equal((await runner('move_camera', { motion: 'tilt', pitchDeg: -45 })).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tilt with no voice flight pending runs at once', async () => {
|
||||||
|
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
|
||||||
|
const runner = createGevActionRunner({
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager: { layers: new Map(), getAll: () => [] },
|
||||||
|
});
|
||||||
|
await runner('fly_to_location', { locationId: 'austin' });
|
||||||
|
flights[0].complete();
|
||||||
|
const result = await runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => {
|
test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => {
|
||||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||||
|
|||||||
Reference in new issue
Block a user