Street Level: a landed link, a replaced photo and a retired lookup keep their state

- A link's photo step compares with the navigation token its own restore
  flight owned, so a navigation after the flight landed (while the layers
  still load) keeps the camera.
- A request that switches the source keeps the open photo up until it is
  replaced (holdViewer), so EXPAND stays across sources.
- A lookup given up while it looked leaves the open photo and the panel as
  they were, also when it found nothing or failed.
- A failed replacement shows its error inside the expanded viewer.
- A replacing lookup stops a playing Mapillary sequence before the found
  photo opens.
- The action refuses coordinates outside the globe, keeps a name given with
  coordinates as its label, and fly: false keeps the camera only for
  coordinates (its test-only entity and query branches are gone).
- Numbered entities carry their label; a view's street-level point is a pair.
- Mapillary's nearest search always asks for 50 m and 100 images.
- Tests: the lifecycle reproductions, PLAY on replacement, out-of-range
  coordinates, half a point, and the named label; three layer tests share
  enabledLayer; the second payload digest is gone.
- Docs: the tool count, the panel freshness rule, armed: false, the photo
  replacement, the browser-gate comment and a shorter changelog fragment.
This commit is contained in:
German Khokhlov committed 2026-11-10 23:48:57 +04:00
1 parent 9c73939425
commit f3996da0af
26 files changed
+470 -327

No files matched your search

+8 -2
View File
@@ -341,10 +341,16 @@ export function installViews({
// A restore whose camera never landed (the recipient dragged, searched or
// opened another place during its flight) leaves the camera with them.
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
// The generation the restore's own flight owned: a navigation after it
// landed, while the layers still loaded, has moved on from it.
const restoreGeneration = (outcome) =>
Number.isInteger(outcome?.navigationToken)
? outcome.navigationToken
: shell._navigationGeneration;
let generationAtRestore = null;
let startSuperseded = false;
let queue = ready.then((outcome) => {
generationAtRestore = shell._navigationGeneration;
generationAtRestore = restoreGeneration(outcome);
startSuperseded =
outcome?.status === 'superseded' ||
(Boolean(outcome?.share) && !cameraLanded(outcome));
@@ -414,7 +420,7 @@ export function installViews({
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (!streetLevel) return;
const generation = shell._navigationGeneration;
const generation = restoreGeneration(event.detail);
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
};
windowRef?.addEventListener?.(
+41 -13
View File
@@ -266,8 +266,11 @@ export function createStreetLevelLayer({
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
}
/** Requests replacing the open photo that switch its source keep it up. */
let viewerHolds = 0;
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
if (!viewerHolds) parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
@@ -280,6 +283,13 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
/** Close the image and deselect its sequence on the map. */
function closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
}
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas = state.viewer?.scene?.canvas;
@@ -692,15 +702,17 @@ export function createStreetLevelLayer({
* street); clicks keep the default. A caller's `signal` retires the
* lookup: nothing opens after it fires. Providers without
* `nearestImage` are skipped. `armed: false` (a request of its own, such
* as the assistant's) neither uses nor drops a link's armed point. Why
* as the assistant's) does not use a link's armed point, and keeps it
* while it looks; a photo it opens ends it, as any opened photo does. Why
* nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery
* in reach), 'sky' (the screen centre is not ground), or null (an error).
* `replace: true` (a request that takes over the panel) keeps an open
* photo up while it looks, so the viewer and EXPAND stay, and closes it
* when the lookup opens nothing: the panel shows this request's outcome.
* `proceed()` is asked once the lookup has found an image and before it
* loads; false retires the request (a photo that bills every load must
* not load for a request its caller has given up).
* `proceed()` is asked before a found image loads and before an empty
* or failed lookup reports; false retires the request and leaves the
* panel as it was (a photo that bills every load must not load for a
* request its caller has given up).
*/
async openNearest(
point,
@@ -722,9 +734,7 @@ export function createStreetLevelLayer({
const armed = useArmed ? state.street.armed : null;
if (useArmed) state.street.armed = null;
const closeReplaced = () => {
if (!replace || !state.street.open) return;
parts.viewerHost.close();
parts.clearSequences();
if (replace && state.street.open) closeViewer();
};
if (!point && aim === 'screen-centre' && armed) {
// The button promises the centre of the view: the link's spot only
@@ -812,6 +822,9 @@ export function createStreetLevelLayer({
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
callerSignal?.removeEventListener('abort', onCallerAbort);
// The photo asked for stays on screen: a sequence playing in the
// photo it replaces stops.
if (replace && state.street.open) parts.viewerHost.stopPlayback();
// The caller's signal still counts while the viewer mounts and loads.
return openImage(entry.def.id, imageId, {
frame,
@@ -821,6 +834,9 @@ export function createStreetLevelLayer({
callerSignal?.removeEventListener('abort', onCallerAbort);
// Replaced while it looked: the panel is the newer lookup's.
if (nearestLookup !== lookup) return false;
// Given up by its caller while it looked: the open photo stays, and
// the panel reports nothing of this lookup.
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
closeReplaced();
// Switched off while it looked: nothing to open, nothing to report.
@@ -848,11 +864,23 @@ export function createStreetLevelLayer({
notify();
return false;
},
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
closeViewer,
/**
* Keep the open photo up while a request that replaces it switches the
* source (the viewer and EXPAND are replaced, not closed). The returned
* release closes it if its source is still off by then.
*/
holdViewer() {
viewerHolds += 1;
let held = true;
return () => {
if (!held) return;
held = false;
viewerHolds -= 1;
if (viewerHolds) return;
for (const entry of state.providers.values())
if (!entry.on) parts.viewerHost.unmount(entry.def.id);
};
},
/**
* Prepare a nearest lookup for the next OPEN press without running it: a
+34 -56
View File
@@ -366,7 +366,11 @@ function fakeViewer() {
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
async function enabledLayer(
t,
providers = [fakeProvider()],
{ services, setup } = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
@@ -374,7 +378,11 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
setup?.(viewer);
const layer = createStreetLevelLayer({
providers,
...(services ? { services } : {}),
});
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
@@ -1944,7 +1952,6 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
});
test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
@@ -1953,26 +1960,18 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
// Every pick misses the ground: the screen centre is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.globe = { pick: () => undefined };
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
// Every pick misses the ground: the screen centre is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.globe = { pick: () => undefined };
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
},
});
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
assert.equal(
@@ -1986,7 +1985,6 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link
});
test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
@@ -1995,25 +1993,18 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) =>
at.x === 500 && at.y === 300 ? centre : undefined,
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
},
});
// About 120 m away: an OPEN press would take the link's spot.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
@@ -2030,25 +2021,12 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar
});
test('a failing source is not reported as missing imagery', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const provider = fakeProvider({
nearestImage: async () => {
throw new Error('Mapillary graph HTTP 500');
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = create({ providers: [provider] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, null);
+2 -3
View File
@@ -532,9 +532,8 @@ export function createLineCoverage({
}
/**
* The sequence lines drawn now, as `[lon, lat]` parts per sequence: for
* diagnostics and the browser gate, which aims its clicks at them instead
* of scanning pixels: what each tile drew (`attachPrimitive` keeps it in
* The sequence lines drawn now, as `[lon, lat]` parts per sequence, for
* diagnostics: what each tile drew (`attachPrimitive` keeps it in
* `entry.sequences`). Read-only copies.
*/
function drawnLines() {
@@ -6,7 +6,6 @@ import {
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { coverageZoomForHeight } from '../../tileMath.js';
import { passesImageryFilter } from '../../filter.js';
import { NEAREST_RADIUS_M } from '../../policy.js';
import { metresBetween } from '../../view.js';
import { decodeCoverageTile } from './decode.js';
import { createMapillaryViewer } from './viewer.js';
@@ -20,7 +19,6 @@ import {
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
} from './policy.js';
@@ -206,10 +204,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
* cone). The core aborts `signal` once a newer request overtakes it.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
const images = await source.nearestImages({ lat, lon }, { signal });
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
@@ -200,11 +200,7 @@ test('nearestImage opens the closest image that passes the filter, not the first
);
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near');
assert.equal(requests[0].options.signal, signal, 'the core can abort it');
assert.equal(requests[0].query.radius, 50);
// The API cuts at the limit before any order: a small one would sort and
// filter a handful of arbitrary photos (a 360° filter could find none).
assert.equal(requests[0].query.limit, 100, "the API's most");
assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]);
assert.deepEqual(requests[0].query, { lat: 1, lon: 2 });
const any = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano');
const tooOld = createMapillaryProvider({ source }).create(
@@ -0,0 +1,56 @@
import '../../../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createStreetLevelLayer } from '../../index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from '../../../../testSupport/streetLevelFakes.mjs';
import { createMapillaryViewer } from './viewer.js';
import { showStreetLevel } from '../../../../voice/gevActions.js';
// Library boundary fixture: moveTo changes the image without stopping PLAY,
// matching installed MapillaryJS Viewer.moveTo / Navigator.moveTo$.
const fakeLibrary = `export class Viewer {
constructor() { globalThis.__replacePlayViewer = this; this.playing = false; this.id = null; this.stops = 0; }
on() {} remove() {} resize() {} setRenderMode() {}
getComponent() { return { play: () => {this.playing = true;}, stop: () => {this.playing = false; this.stops++;} }; }
async moveTo(id) { this.id = id; return { id, sequenceId: 'fixture-sequence', cameraType: 'perspective' }; }
async getPosition() { return { lng: 2, lat: 48 }; }
async getPointOfView() { return { bearing: 0 }; }
}
export class GraphDataProvider {}
export const RenderMode = { Fill: 1, Letterbox: 0 };`;
const hooks = registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mapillary-js') return { url: 'data:text/javascript,' + encodeURIComponent(fakeLibrary), shortCircuit: true };
if (specifier.endsWith('mapillary.css')) return { url: 'data:text/javascript,export default {}', shortCircuit: true };
return next(specifier, context);
},
});
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
test('a photo the action asks for stops sequence PLAY', async t => {
const provider = fakeStreetLevelProvider({ nearestImage: async () => 'B' });
const create = provider.create;
provider.create = context => ({ ...create(context), viewer: createMapillaryViewer({ source: { token: '' } }) });
const viewer = fakeCesiumViewer(); viewer.canvas = viewer.scene.canvas;
const layer = createStreetLevelLayer({ providers: [provider] });
layer.init(viewer); layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); hooks.deregister(); delete globalThis.__replacePlayViewer; document.body.replaceChildren(); });
await Promise.resolve();
await layer.openImage('mapillary', 'A', { frame: false });
const libraryViewer = globalThis.__replacePlayViewer;
libraryViewer.getComponent('sequence').play();
assert.equal(libraryViewer.playing, true);
const manager = {
layers: new Map([['street-level', { module: layer }]]), isEnabled: () => true,
getLayerParams: () => layer.getParams(), setLayerParams: (_id, params) => layer.setParams(params),
};
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, {
viewer, dataManager: manager, styleManager: { _navigationGeneration: 1 },
runGevAction: async () => ({ ok: true, latitude: 48, longitude: 2, label: 'fixture destination' }),
});
assert.equal(result.ok, true);
assert.equal(libraryViewer.id, 'B');
assert.equal(layer.getUIState().street.open, true);
assert.equal(libraryViewer.playing, false, 'requested image must stay selected rather than continue sequence PLAY');
});
@@ -196,18 +196,15 @@ export function createMapillarySource({
return Array.isArray(payload?.data) ? payload.data : [];
},
/** Images within `radius` metres of a point, in no particular order. */
async nearestImages(
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
{ signal } = {},
) {
/** Images within `NEAREST_RADIUS_M` of a point, in no particular order. */
async nearestImages({ lat, lon }, { signal } = {}) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(Math.min(50, Math.max(1, radius))),
limit: String(Math.min(100, Math.max(1, limit))),
radius: String(NEAREST_RADIUS_M),
limit: String(NEAREST_LIMIT),
fields: NEAREST_IMAGE_FIELDS,
},
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
@@ -126,24 +126,21 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
);
});
test('nearestImages asks the graph API around a point, its radius and limit clamped', async () => {
test('nearestImages asks the graph API around a point, within 50 m, for at most 100 images', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.nearestImages({
lat: 38.5,
lon: -121.5,
radius: 500,
limit: 500,
});
const images = await source.nearestImages({ lat: 38.5, lon: -121.5 });
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('lat'), '38.5');
assert.equal(url.searchParams.get('lng'), '-121.5');
assert.equal(url.searchParams.get('radius'), '50', 'the API maximum');
assert.equal(url.searchParams.get('limit'), '100');
assert.equal(url.searchParams.get('radius'), '50');
// The API cuts at the limit before any order: a small one would sort and
// filter a handful of arbitrary photos (a 360° filter could find none).
assert.equal(url.searchParams.get('limit'), '100', "the API's most");
assert.match(url.searchParams.get('fields'), /computed_geometry/);
assert.match(url.searchParams.get('fields'), /is_pano/);
});
@@ -174,6 +174,15 @@ export function createMapillaryViewer({ source, render } = {}) {
current = null;
}
/** A playing sequence keeps stepping through (and downloading) images. */
function stopPlayback() {
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
}
return {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
@@ -195,15 +204,12 @@ export function createMapillaryViewer({ source, render } = {}) {
close() {
pendingOpen = null;
current = null;
// A playing sequence would keep stepping through (and downloading)
// images in the hidden viewer.
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
// Hidden, the viewer must not keep playing.
stopPlayback();
},
stopPlayback,
unmount() {
pendingOpen = null;
// A build still waiting for the library gives up when it arrives.
+1 -1
View File
@@ -51,7 +51,7 @@ export function filtersOf(provider) {
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
* @property {() => Array<{id: string, parts: Array<Array<[number, number]>>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate).
* @property {() => Array<{id: string, parts: Array<Array<[number, number]>>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics).
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it.
* @property {ViewerAdapter} viewer
*
+6 -1
View File
@@ -175,6 +175,11 @@ export function createViewerHost({ state, parts }) {
active?.adapter.resize();
}
/** Stop a sequence playing in the open photo (adapters that play). */
function stopPlayback() {
active?.adapter.stopPlayback?.();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
active?.adapter.setRenderMode?.(state.street.renderMode);
@@ -197,5 +202,5 @@ export function createViewerHost({ state, parts }) {
active = null;
}
return { attach, open, close, resize, setRenderMode, unmount };
return { attach, open, close, resize, setRenderMode, stopPlayback, unmount };
}
+194
View File
@@ -0,0 +1,194 @@
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { ShareRestoration } from './ui/shareRestoration.js';
import { createView } from './view/index.js';
import { StreetLevelControls } from './ui/streetLevelControls.js';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import StreetLevelPanel from './ui/views/panels/StreetLevelPanel.vue';
import { useStreetLevelStore } from './ui/stores/streetLevel.js';
// Street-level request lifecycle across the action, the layer, links and the
// panel. Offline: providers and camera flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function harness(t, { nearestImage = async () => 'A', failImage = null, billed = false, extraProviders = [] } = {}) {
document.body.className = '';
document.body.replaceChildren();
const provider = fakeStreetLevelProvider({ nearestImage });
provider.billedPerImage = billed;
const originalCreate = provider.create;
const opened = [];
provider.create = context => {
const instance = originalCreate(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async id => {
opened.push(id);
if (id === failImage) throw new Error('Fixture image load failed');
pose?.({ imageId: id });
};
return instance;
};
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: [provider, ...extraProviders] });
layer.init(viewer);
layer.enable(viewer);
const host = document.body.appendChild(document.createElement('div'));
layer.attachViewerHost(host);
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
assert.equal(layer.providerStatus('mapillary').billed, billed);
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
getAll: () => [{ id: 'street-level', enabled: true }],
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true }),
setLayerParams: (_id, params) => layer.setParams(params),
};
const styleManager = { _navigationGeneration: 1 };
const flights = [];
const context = {
viewer, dataManager, styleManager,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' };
},
};
let result;
const run = async (name, args) => {
if (name !== 'show_street_level') return { ok: true };
result = await showStreetLevel(args, context);
return result;
};
const view = createView({ camera: { lat: 48, lon: 2 }, layers: ['street-level'], street_level: { provider: 'mapillary', lat: 48, lon: 2 } });
return { layer, viewer, dataManager, context, run, view, opened, flights, result: () => result };
}
test('a landed link does not reclaim a camera the user moved while its layers load', async t => {
const h = await harness(t);
const location = { search: '', hash: '' };
const shell = h.context.styleManager;
shell.initialRestorePromise = Promise.resolve();
const remove = installViews({ ...h, shell, location, windowRef: window });
t.after(remove);
await settle();
location.hash = '#sl=mapillary,48,2';
const layers = deferred();
const landed = deferred();
const restoration = Object.assign(Object.create(ShareRestoration.prototype), {
_shareRestoreGeneration: 1, _disposed: false,
shareLinkManager: {
applyState: async () => { landed.resolve(); return { camera: 'applied' }; },
completeInitialRestore() {},
},
_sharedVessel: () => null,
});
const restoring = restoration._runShareRestore({}, {
applyCamera: true, navigationToken: 1, layersPromise: layers.promise,
settle: detail => window.dispatchEvent(new CustomEvent('gev:share-navigation-restore-settled', { detail })),
});
await landed.promise;
shell._navigationGeneration++;
layers.resolve([]);
await restoring;
await settle(); await settle();
assert.equal(h.flights.length, 0, 'an old landed link must not fly after later navigation');
});
test('a replacement from another source keeps EXPAND', async t => {
const h = await harness(t, {
extraProviders: [fakeStreetLevelProvider({ id: 'panoramax', pickPrefix: 'pm:', nearestImage: async () => 'B' })],
});
const root = document.body.appendChild(document.createElement('div'));
root.innerHTML = '<button id="sl-status"></button><div id="sl-viewer-wrap"><div id="sl-viewer"></div></div><button id="sl-viewer-expand"></button>';
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const store = { attach: () => 1, detach() {}, publish() {}, setExpandAvailable() {}, setExpanded() {}, setEmbedOverlay() {} };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
assert.equal(await h.layer.openImage('mapillary', 'previous', { frame: false }), true);
await controls._toggleExpanded();
assert.equal(controls.isViewerExpanded(), true);
const result = await showStreetLevel({ provider: 'panoramax', latitude: 48, longitude: 2 }, h.context);
assert.equal(result.ok, true);
assert.equal(h.layer.getUIState().street.providerId, 'panoramax');
assert.equal(controls.isViewerExpanded(), true, 'replacement must preserve the expanded viewing surface');
});
for (const outcome of ['empty', 'error', 'image']) {
test(`a replacement retired by a navigation keeps the open photo (${outcome})`, async t => {
const lookup = deferred();
const entered = deferred();
const h = await harness(t, { nearestImage: async () => {
entered.resolve(); await lookup.promise;
if (outcome === 'error') throw new Error('Fixture lookup failure');
return outcome === 'image' ? 'B' : null;
} });
await h.layer.openImage('mapillary', 'A', { frame: false });
const action = showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await entered.promise;
h.context.styleManager._navigationGeneration++;
lookup.resolve();
const result = await action;
assert.ok(result.cancelled || result.superseded);
assert.deepEqual(h.opened, ['A']);
assert.equal(h.layer.getUIState().street.open, true, 'retired lookup must preserve A');
assert.equal(h.layer.getUIState().street.error, null, 'retired lookup must not publish an error');
});
}
test('a failed replacement shows its error inside the expanded viewer', async t => {
const h = await harness(t, { nearestImage: async () => 'B', failImage: 'B' });
const root = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(StreetLevelPanel); app.use(pinia); app.mount(root);
const store = useStreetLevelStore(pinia);
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); app.unmount(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
await h.layer.openImage('mapillary', 'A', { frame: false });
await nextTick();
await controls._toggleExpanded();
await settle();
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await nextTick();
assert.equal(result.ok, false);
assert.equal(controls.isViewerExpanded(), true);
assert.equal(document.fullscreenElement, wrap);
assert.equal(h.layer.getUIState().street.imageId, 'A');
assert.match(h.layer.getUIState().street.error, /Fixture image load failed/);
assert.equal(wrap.contains(root.querySelector('#sl-error')), false);
const error = wrap.querySelector('#sl-viewer-error');
assert.match(error.textContent, /Fixture image load failed/);
assert.equal(error.hidden, false, 'error must be visible inside fullscreen');
});
+3 -1
View File
@@ -263,7 +263,9 @@ export class ShareRestoration {
// the shared flight having landed.
const vessel = this._sharedVessel(state);
if (vessel) void this._restoreSharedVessel(vessel, current);
settle({ status: 'settled', share, layers, tracking });
// The token tells listeners whether anyone navigated after the
// restore's flight landed (while the layers were still loading).
settle({ status: 'settled', share, layers, tracking, navigationToken });
} catch (error) {
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
+2 -2
View File
@@ -142,7 +142,7 @@ const vThumb = {
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
</div>
</div>
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!(embedOverlay || expanded) || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
@@ -159,7 +159,7 @@ const vThumb = {
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
</div>
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || expanded || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
+4 -3
View File
@@ -354,10 +354,11 @@ export function createView({
);
if (needs) map = needs;
// The point defaults to where the camera looks: a view "of X" opens
// photos at X, also when the camera sits behind it.
// photos at X, also when the camera sits behind it. It is a pair: half
// of one is not mixed with the camera's other half.
const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)];
const seen = lookAtPoint(camera);
const lat = latitude(streetLevel.lat) ?? seen.lat;
const lon = longitude(streetLevel.lon) ?? seen.lon;
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
street = Object.freeze({
...(provider ? { provider } : {}),
lat: round(clamp(lat, -90, 90), 5),
+10
View File
@@ -208,6 +208,16 @@ test('street-level photos travel in the link, light their source and bring their
assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null);
assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null);
assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null);
// Half a point is not mixed with the camera's other half.
const half = createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'mapillary', lat: 40 },
});
assert.deepEqual(half.street_level, {
provider: 'mapillary',
lat: 1,
lon: 2,
});
});
test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => {
+2 -23
View File
@@ -58,32 +58,11 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// control_cockpit states the own-layer entry rule (Contacts not required).
// move_camera tilt takes an optional pitchDeg angle, described as
// absolute with its -45 default, with steps sent as a direction.
// show_street_level (map, source, photo) is the only change on top of main.
SHOW_STREET_LEVEL_PAYLOAD_DIGEST,
);
// Every shipped tool is byte-identical to the previous release: the new
// tool is the whole difference, so a later edit cannot hide in a re-pin.
const shipped = createHash('sha256')
.update(
JSON.stringify(
stable(
GEV_REALTIME_TOOLS.filter(
(tool) =>
!['set_cyber_sonar', 'show_street_level'].includes(tool.name),
),
),
),
)
.digest('hex');
assert.equal(
shipped,
'6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c',
// show_street_level (map, source, photo) adds one tool.
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468',
);
});
const SHOW_STREET_LEVEL_PAYLOAD_DIGEST =
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468';
test('descriptions customize wording without changing immutable shared arguments', () => {
const descriptions = {
fly_to_location: {
+2
View File
@@ -143,6 +143,8 @@ function resolveFly(args, { pointer, referents }) {
entity: {
layerId: target.layerId,
id: target.id,
// The answer names it, not its id ("near cam-9").
...(target.label ? { label: target.label } : {}),
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
},
},
+1 -1
View File
@@ -104,7 +104,7 @@ test('referents: numbered items resolve for track, fly and context', () => {
const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents });
assert.equal(last.args.query, 'ccc333', '-1 is the last one');
const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents });
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time');
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111', label: 'SWA12' } }, 'moving contacts are located at run time');
const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents });
assert.equal(context.args.scope, 'target');
assert.equal(context.args.target.label, 'AAL9');
+32 -40
View File
@@ -3813,24 +3813,26 @@ function streetLevelPanelHidden() {
}
/**
* "Show me <place> on Street View / on Mapillary": fly to the place, make sure
* the imagery source's map is up, light that source in Street Level, turn the
* layer on and open the nearest image. One source at a time, as the panel
* does; the choice persists like a chip click. The key is checked before
* anything changes, and each stage reports its own failure so speech can say
* which part did not happen. With no place at all, the view centre is used.
* `autoOpen: false` (links, not the model) stops short of loading a photo
* from a source that bills every load: the panel is armed and the OPEN
* button is one click away.
* "Show me <place> on Street View / on Mapillary": fly there, light the source
* (and its map) and open the nearest image. Each stage reports its own
* failure, so speech can say which part did not happen. `autoOpen: false`
* (links) stops short of a billed load: the panel is armed for OPEN.
*/
export async function showStreetLevel(args, context) {
// Every await can hand the request's moment to someone else: a newer turn,
// a navigation or gesture that takes the camera, or a mode that hides the
// panel. Each stage after the place is settled checks again.
const owner = watchCameraOwner(context.viewer, context.styleManager);
// A photo held up while the request switches its source is let go however
// the request ends: replaced by then, or closed if its source stayed off.
let releaseViewer = null;
const holdViewer = (layerModule) => {
releaseViewer ??= layerModule.holdViewer?.() ?? null;
};
try {
return await requestStreetLevel(args, { ...context, owner });
return await requestStreetLevel(args, { ...context, owner, holdViewer });
} finally {
releaseViewer?.();
owner.stop();
}
}
@@ -3840,9 +3842,9 @@ async function requestStreetLevel(
{
dataManager,
styleManager,
placeSearch,
runGevAction,
owner,
holdViewer = () => {},
runOptions = {},
current = () => true,
reportProgress = () => {},
@@ -3868,11 +3870,14 @@ async function requestStreetLevel(
const query = String(args.query || '').trim();
const latitude = Number(args.latitude);
const longitude = Number(args.longitude);
const coordinatesGiven = args.latitude != null && args.longitude != null;
const hasCoordinates =
args.latitude != null &&
args.longitude != null &&
Number.isFinite(latitude) &&
Number.isFinite(longitude);
coordinatesGiven && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180;
if (coordinatesGiven && !hasCoordinates)
return fail(
'location',
'Latitude must be within ±90° and longitude within ±180°',
);
// A referent that is a layer entity (deixis): located when the flight starts.
const entity =
args.entity && typeof args.entity === 'object' && args.entity.id
@@ -3952,29 +3957,11 @@ async function requestStreetLevel(
atViewCentre = !query && !hasCoordinates && !entity;
let point = null;
let location = null;
if (args.fly === false || atViewCentre) {
// No place at all: the layer looks at the ground under the screen centre,
// as the OPEN button does. `fly: false` (tests) keeps the camera for a
// point given by its coordinates.
if (atViewCentre || (args.fly === false && hasCoordinates)) {
if (hasCoordinates) point = { lat: latitude, lon: longitude };
else if (entity) {
// Where it is now, as the flying path does: the coordinates a list
// saved go stale as soon as the item moves or unloads.
const located = locateLayerEntity(dataManager, entity);
if (!located) return fail('location', `${label} is no longer loaded`);
point = { lat: located.lat, lon: located.lon };
} else if (query) {
reportProgress('search', query);
const outcome = await placeSearch.geocode(query, {
signal: runOptions.signal,
});
const place = outcome?.place;
if (!current())
return fail('location', 'Street-level request was cancelled');
if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng))
return fail('location', `Could not find ${query}`);
point = { lat: place.lat, lon: place.lng };
label = place.label || query;
}
// No place at all: the layer looks at the ground under the screen
// centre, as the OPEN button does.
} else {
location = await runGevAction(
'fly_to_location',
@@ -3995,8 +3982,10 @@ async function requestStreetLevel(
location?.error || `Could not arrive at ${location?.label || label}`,
{ cancelled: !current() || Boolean(location?.cancelled), location },
);
// An item keeps its name: the flight to it reports only coordinates.
label = entity ? label : location.label || label;
// An item, or a named place flown to by its coordinates, keeps its name:
// such a flight reports only coordinates.
label =
entity || (query && hasCoordinates) ? label : location.label || label;
if (
!Number.isFinite(location.latitude) ||
!Number.isFinite(location.longitude)
@@ -4037,7 +4026,10 @@ async function requestStreetLevel(
if (stop) return stop;
}
// Light the source the way a chip click does, so the choice is kept.
// Light the source the way a chip click does, so the choice is kept. An
// open photo of another source stays up until this request replaces it.
if (layerModule.getUIState?.()?.street?.open === true)
holdViewer(layerModule);
const switches = Object.fromEntries(
providerIds.map((id) => [id, id === provider]),
);
+18 -90
View File
@@ -6660,11 +6660,11 @@ function createStreetLevelHarness({
return false;
}
}
// Like the layer: a replacing lookup that opens nothing closes the photo.
// Like the layer: a lookup its caller gave up leaves the panel alone,
// and a replacing lookup that opens nothing closes the photo.
if (options.proceed && options.proceed() !== true) return false;
const closeReplaced = () => {
if (!options.replace || !street.open) return;
order.push('close');
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
if (options.replace && street.open) layer.closeViewer();
};
if (imageryError) {
closeReplaced();
@@ -6678,8 +6678,6 @@ function createStreetLevelHarness({
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false;
}
// Found: it loads only if the request still wants it.
if (options.proceed && options.proceed() !== true) return false;
street.open = true;
street.imageId = nearest;
street.title = '10th St, Sacramento';
@@ -6762,6 +6760,14 @@ test('show_street_level flies first, then switches the map, lights the source, t
assert.equal(result.imageId, 'pano-a');
assert.equal(result.title, '10th St, Sacramento');
assert.equal(result.flew, true);
// A name given with its coordinates is what the answer says.
const named = createStreetLevelHarness();
const naming = named.runner('show_street_level', {
query: 'Capitol Mall', latitude: 38.5799, longitude: -121.4937, provider: 'google',
});
await new Promise((resolve) => setImmediate(resolve));
named.finishFlight();
assert.equal((await naming).label, 'Capitol Mall');
});
test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => {
@@ -6799,6 +6805,11 @@ test('show_street_level reports each stage it could not pass', async () => {
assert.equal(none.label, 'the view centre');
assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']);
const offGlobe = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const outOfRange = await offGlobe.runner('show_street_level', { latitude: 95, longitude: 2, provider: 'google' });
assert.deepEqual([outOfRange.ok, outOfRange.stage], [false, 'location']);
assert.deepEqual(offGlobe.order, [], 'nothing flies, switches or looks up');
const unknown = await createStreetLevelHarness().runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false,
});
@@ -6865,15 +6876,6 @@ test('show_street_level opens photos of a numbered item or a pointed spot throug
assert.equal(result.ok, true, result.error);
assert.equal(result.label, 'Camera 9');
assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']);
// Without a flight the entity's own position is used.
const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }));
const kept = await still.runner('show_street_level', {
provider: 'google',
fly: false,
entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 },
});
assert.equal(kept.ok, true, kept.error);
assert.deepEqual(still.order, ['params:google:voice', 'open:google:250:false']);
});
test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => {
@@ -7003,43 +7005,9 @@ test('show_street_level tells a failed enable, a delisted source, a key refused
assert.deepEqual(unconfigured.order, [], 'nothing changed');
});
test('show_street_level without a flight geocodes a name, locates an item, and reports what it cannot place', async () => {
test('show_street_level reports a flight that lands without a position', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const points = [];
const open = hit.layer.openNearest;
hit.layer.openNearest = (point, options) => {
points.push(point);
return open(point, options);
};
const runner = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: hit.dataManager,
placeSearch: {
geocode: async (query) =>
query === 'Capitol Mall'
? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } }
: { place: null, answered: true },
},
});
const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false });
assert.equal(found.ok, true, found.error);
assert.equal(found.label, 'Capitol Mall, Sacramento');
assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 });
assert.equal(found.flew, false);
const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false });
assert.deepEqual([miss.ok, miss.stage], [false, 'location']);
assert.equal(miss.say, "Couldn't find Atlantis Boulevard.");
assert.equal(points.length, 1, 'a miss asks for no imagery');
const gone = await runner('show_street_level', {
provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' },
});
assert.deepEqual([gone.ok, gone.stage], [false, 'location']);
assert.equal(gone.say, "Couldn't find Camera 7.");
// A flight that reports no coordinates cannot be followed by a lookup.
const noPoint = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
@@ -7105,20 +7073,6 @@ test('show_street_level says "refused its key" when Google already refused it, a
assert.match(dark.error, /could not be switched on/);
});
test('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
// The referent carries the coordinates the list saved, but it is no longer loaded.
const stale = await harness.runner('show_street_level', {
provider: 'google',
fly: false,
entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 },
});
assert.deepEqual([stale.ok, stale.stage], [false, 'location']);
assert.match(stale.error, /no longer loaded/);
assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point');
});
test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => {
const { activeImageryFilter } = await import('./gevActions.js');
const both = { pano: true, since: true };
@@ -7281,32 +7235,6 @@ test('a partial analyst card shows one partial chip', () => {
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
});
test('show_street_level superseded during its geocode changes nothing', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true });
let current = true;
const runner = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async (id) => {
h.order.push(`map:${id}`);
return { ok: true };
},
},
dataManager: h.dataManager,
placeSearch: {
geocode: async () => {
current = false;
return { place: { lat: 1, lng: 2, label: 'X' } };
},
},
});
const result = await runner('show_street_level', { query: 'X', provider: 'google', fly: false }, { isCurrent: () => current });
assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm)/.test(step)), [], 'the stale turn switched nothing');
assert.deepEqual([result.ok, result.stage, result.cancelled], [false, 'location', true]);
});
test('show_street_level superseded during its flight switches nothing', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
+3 -11
View File
@@ -343,17 +343,9 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
tool('annotate_map').annotations.items.properties.target.description,
/"pointer"/,
);
// Budget: the whole Realtime prompt (instructions + tools), measured in
// characters. It was 56,308 after the area/imagery/OSM merge; stating each
// policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
// the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,103 as measured
// below (every tool, osm_query included); main's Sea Marks and Ski Pistes
// layers bring it to 55,210, and the tilt pitchDeg argument (with its -45
// default) and main's cockpit entry rule to 55,339. show_street_level (the
// tool and its routing rule, ~940) raises it to 56,500. A new tool or rule
// has to fit under this ceiling.
// Budget: the whole Realtime prompt (instructions + every tool, osm_query
// included), in characters. Every session pays for it in latency and cost,
// so a new tool or rule has to fit under this ceiling or earn a raise.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 56_500, `prompt size ${size}`);
});