mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
fix(street-level): links keep the recipient's preferences; aim under the screen centre
Review fixes for gekh's #1113 round: - a link runs show_street_level with origin 'programmatic', so its source switch and layer are not saved as the recipient's preferences; - with no place named, the action looks under the screen centre (aim 'screen-centre', armed false), keeps the sky refusal and leaves a link's armed point alone; - an armed OPEN aimed at the sky opens nothing instead of the far link point; - an imagery-stage failure is spoken as an error, missing imagery as missing (street.nearestOutcome); the card says "the view centre" in the interface language; - every photo the action opens reveals its panel, and revealPanel scrolls the panel into view in the phone sheet; - embed: EXPAND survives embed's own clean view, the lifted photo hides in cockpit, recording and scene playback, and a failed photo step closes the earlier photo before the view flies; - links pasted into an open tab run their street-level step after the restore settles; a superseded start link does not; - Mapillary's nearest search gives up after 12 s, body included.
This commit is contained in:
1 parent
3f55077c2c
commit
451f3e41cf
22 files changed
+716
-64
No files matched your search
+74
-29
@@ -198,6 +198,8 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
|
||||
}
|
||||
|
||||
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
|
||||
/** Dispatched on the window when a link opened after start-up has restored. */
|
||||
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
|
||||
|
||||
/** The armed link's toast, naming the OPEN button as the panel shows it. */
|
||||
function armedToast() {
|
||||
@@ -258,8 +260,12 @@ export async function applyView(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level),
|
||||
);
|
||||
if (result?.ok !== true && !result?.location)
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
if (result?.ok !== true) {
|
||||
// An earlier view's photo must not stay over a place this view left.
|
||||
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
|
||||
if (!result?.location)
|
||||
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
if (!view.follow) {
|
||||
@@ -317,13 +323,16 @@ export function installViews({
|
||||
// The camera's owner at restore: anyone who navigates while the link's
|
||||
// steps wait (a gesture, a search, the assistant) takes it, and a passive
|
||||
// link must not fly over them.
|
||||
// A start-up restore that a newer link superseded hands the camera to that
|
||||
// link: its own steps never run.
|
||||
let generationAtRestore = null;
|
||||
let queue = ready.then(() => {
|
||||
let startSuperseded = false;
|
||||
let queue = ready.then((outcome) => {
|
||||
generationAtRestore = shell._navigationGeneration;
|
||||
startSuperseded = outcome?.status === 'superseded';
|
||||
});
|
||||
const navigatedSinceRestore = () =>
|
||||
Number.isFinite(generationAtRestore) &&
|
||||
shell._navigationGeneration !== generationAtRestore;
|
||||
const navigatedSince = (generation) =>
|
||||
Number.isFinite(generation) && shell._navigationGeneration !== generation;
|
||||
const linkStep = (work) =>
|
||||
queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
@@ -341,29 +350,64 @@ export function installViews({
|
||||
// click; only the panel page's own link for a result issued just now is
|
||||
// the client's request (a host remounting the panel hands back old ones).
|
||||
// A link that follows an entity keeps the camera on it.
|
||||
const streetLevel = followFromParams(hashParams)
|
||||
? null
|
||||
: streetLevelFromParams(hashParams);
|
||||
if (streetLevel)
|
||||
queue = linkStep(async () => {
|
||||
if (navigatedSinceRestore()) return;
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }),
|
||||
);
|
||||
// The restore window keeps the panel collapsed; OPEN must be in view.
|
||||
// Embed mode hides the rail that holds it, so there the link only arms
|
||||
// (a posted view is the way to open a photo in a frame).
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
});
|
||||
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
|
||||
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
shell._showToast?.(armedToast());
|
||||
}
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
const linkedStreetLevel = (params) =>
|
||||
followFromParams(params) ? null : streetLevelFromParams(params);
|
||||
const openLinkedStreetLevel = async (
|
||||
streetLevel,
|
||||
{ generation, autoOpen = false },
|
||||
) => {
|
||||
if (navigatedSince(generation)) return;
|
||||
// The link replays the sender's view: nothing it switches becomes the
|
||||
// recipient's own preference.
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen }),
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
// The restore window keeps the panel collapsed; OPEN must be in view.
|
||||
// Embed mode hides the rail that holds it, so there the link only arms
|
||||
// (a posted view is the way to open a photo in a frame).
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
});
|
||||
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
|
||||
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
shell._showToast?.(armedToast());
|
||||
}
|
||||
};
|
||||
const startStreetLevel = linkedStreetLevel(hashParams);
|
||||
if (startStreetLevel)
|
||||
queue = linkStep(() =>
|
||||
startSuperseded
|
||||
? undefined
|
||||
: openLinkedStreetLevel(startStreetLevel, {
|
||||
generation: generationAtRestore,
|
||||
autoOpen: isFreshPanelResult(),
|
||||
}),
|
||||
);
|
||||
// A link pasted into the open tab restores camera and layers on its own;
|
||||
// its photos follow once that restore settles, under the same rule: a plain
|
||||
// link never loads a billed photo by itself.
|
||||
const onNavigatedRestore = (event) => {
|
||||
if (event?.detail?.status !== 'settled') return;
|
||||
const streetLevel = linkedStreetLevel(
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (!streetLevel) return;
|
||||
const generation = shell._navigationGeneration;
|
||||
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
|
||||
};
|
||||
windowRef?.addEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
const stopNavigatedRestores = () =>
|
||||
windowRef?.removeEventListener?.(
|
||||
SHARE_NAVIGATION_RESTORED_EVENT,
|
||||
onNavigatedRestore,
|
||||
);
|
||||
if (!isEmbedded(location)) return stopNavigatedRestores;
|
||||
|
||||
windowRef.document.body.classList.add('ui-embed');
|
||||
shell.setCleanView?.(true);
|
||||
@@ -423,6 +467,7 @@ export function installViews({
|
||||
});
|
||||
return () => {
|
||||
stopRendering();
|
||||
stopNavigatedRestores();
|
||||
windowRef.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
+128
-2
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
|
||||
});
|
||||
const recorder = (answers = {}) => {
|
||||
const calls = [];
|
||||
const run = async (name, args) => {
|
||||
const options = [];
|
||||
const run = async (name, args, runOptions) => {
|
||||
calls.push([name, args]);
|
||||
options.push([name, runOptions]);
|
||||
const answer = answers[name];
|
||||
return typeof answer === 'function'
|
||||
? answer(calls)
|
||||
: (answer ?? { ok: true });
|
||||
};
|
||||
return { calls, run };
|
||||
return { calls, run, options };
|
||||
};
|
||||
|
||||
test('embed mode is opt-in by query parameter', () => {
|
||||
@@ -875,3 +877,127 @@ test('a passive link stands down when someone navigated while it waited behind i
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
|
||||
});
|
||||
|
||||
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(options, [
|
||||
['show_street_level', { origin: 'programmatic' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a start link whose restore a newer link superseded opens nothing', async () => {
|
||||
const { calls, run } = recorder();
|
||||
installViews({
|
||||
shell: {
|
||||
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
|
||||
_navigationGeneration: 4,
|
||||
},
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||||
},
|
||||
windowRef: new EventTarget(),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
|
||||
});
|
||||
|
||||
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
|
||||
const { calls, run, options } = recorder();
|
||||
const windowRef = new EventTarget();
|
||||
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
|
||||
const shell = {
|
||||
initialRestorePromise: Promise.resolve(),
|
||||
_navigationGeneration: 1,
|
||||
};
|
||||
const remove = installViews({
|
||||
shell,
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location,
|
||||
windowRef,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'the start link has no photos');
|
||||
const settled = (status) =>
|
||||
windowRef.dispatchEvent(
|
||||
Object.assign(new Event('gev:share-navigation-restore-settled'), {
|
||||
detail: { status },
|
||||
}),
|
||||
);
|
||||
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
|
||||
shell._navigationGeneration = 2; // the restore's own flight
|
||||
settled('failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [], 'a failed restore opens nothing');
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'show_street_level',
|
||||
{
|
||||
latitude: 48.8874,
|
||||
longitude: 2.3495,
|
||||
provider: 'mapillary',
|
||||
fly: true,
|
||||
autoOpen: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
assert.deepEqual(options.at(-1), [
|
||||
'show_street_level',
|
||||
{ origin: 'programmatic' },
|
||||
]);
|
||||
remove();
|
||||
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
|
||||
settled('settled');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(calls.length, 1, 'removed with the install');
|
||||
});
|
||||
|
||||
test('a view whose photos fail closes the photo an earlier view opened', async () => {
|
||||
const closed = [];
|
||||
const dataManager = {
|
||||
...fakeLayers(new Set()),
|
||||
layers: new Map([
|
||||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||||
]),
|
||||
};
|
||||
const { run } = recorder({
|
||||
show_street_level: () => ({
|
||||
ok: false,
|
||||
stage: 'key',
|
||||
error: 'needs its key',
|
||||
}),
|
||||
});
|
||||
const view = createView({
|
||||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||||
street_level: { provider: 'google' },
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
await applyView(view, { viewer, dataManager, run });
|
||||
assert.equal(closed.length, 1, 'closed before the view flies away');
|
||||
assert.equal(viewer.flights.length, 1);
|
||||
const opened = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
await applyView(view, { viewer, dataManager, run: opened.run });
|
||||
assert.equal(closed.length, 1, 'an opened photo stays');
|
||||
});
|
||||
+2
-1
@@ -223,7 +223,8 @@ export function createApplicationTools({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
run: (name, args, options = {}) =>
|
||||
voiceCommands.runner(name, args, { ...options, signal }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -668,7 +668,10 @@ export function createStreetLevelLayer({
|
||||
* providers that can (a geocoded landmark is rarely within 50 m of a
|
||||
* street); clicks keep the default. A caller's `signal` retires the
|
||||
* lookup: nothing opens after it fires. Providers without
|
||||
* `nearestImage` are skipped.
|
||||
* `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
|
||||
* nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery
|
||||
* in reach), 'sky' (the screen centre is not ground), or null (an error).
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
@@ -678,18 +681,25 @@ export function createStreetLevelLayer({
|
||||
aim = 'view',
|
||||
radiusM = null,
|
||||
signal: callerSignal = null,
|
||||
armed: useArmed = true,
|
||||
} = {},
|
||||
) {
|
||||
if (callerSignal?.aborted) return false;
|
||||
state.street.nearestOutcome = null;
|
||||
// An OPEN press after a link armed a lookup opens what the link asked;
|
||||
// any other lookup is a new request and drops the armed one.
|
||||
const armed = state.street.armed;
|
||||
state.street.armed = null;
|
||||
const armed = useArmed ? state.street.armed : null;
|
||||
if (useArmed) state.street.armed = null;
|
||||
if (!point && aim === 'screen-centre' && armed) {
|
||||
// The button promises the centre of the view: the link's spot only
|
||||
// while the view still shows it.
|
||||
// while the view still shows it, or when the view cannot be picked.
|
||||
// Sky refuses below, as any OPEN press does.
|
||||
const centre = armed.point ? screenCentreGround() : null;
|
||||
if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) {
|
||||
if (!armed.point) radiusM ??= armed.radiusM;
|
||||
else if (
|
||||
centre === undefined ||
|
||||
(centre && metresBetween(centre, armed.point) <= ARMED_REACH_M)
|
||||
) {
|
||||
point = armed.point;
|
||||
radiusM ??= armed.radiusM;
|
||||
}
|
||||
@@ -704,6 +714,7 @@ export function createStreetLevelLayer({
|
||||
abortNearest();
|
||||
state.street.loading = false;
|
||||
state.street.error = AIM_AT_GROUND_MESSAGE;
|
||||
state.street.nearestOutcome = 'sky';
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
@@ -779,6 +790,8 @@ export function createStreetLevelLayer({
|
||||
);
|
||||
// A provider's own refusal is English data that the panel translates;
|
||||
// the core's refusal is built here, for the panel only, in the language.
|
||||
if (state.enabled && stillOn && !lastError)
|
||||
state.street.nearestOutcome = 'empty';
|
||||
if (state.enabled && stillOn)
|
||||
state.street.error =
|
||||
lastError?.message ||
|
||||
|
||||
@@ -1942,3 +1942,115 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
|
||||
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({
|
||||
nearestImage: async (point) => {
|
||||
lookups.push(point);
|
||||
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;
|
||||
});
|
||||
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
|
||||
assert.equal(
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre' }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky');
|
||||
const street = layer.getUIState().street;
|
||||
assert.equal(street.nearestOutcome, 'sky');
|
||||
assert.match(street.error, /Aim the middle of the view/);
|
||||
});
|
||||
|
||||
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({
|
||||
nearestImage: async (point, options) => {
|
||||
lookups.push({ point, radiusM: options?.radiusM ?? null });
|
||||
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;
|
||||
});
|
||||
// About 120 m away: an OPEN press would take the link's spot.
|
||||
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
|
||||
await layer.openNearest(undefined, { aim: 'screen-centre', armed: false });
|
||||
const asked = lookups.at(-1);
|
||||
assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre');
|
||||
assert.equal(asked.radiusM, null, 'its own radius');
|
||||
assert.equal(layer.getUIState().street.nearestOutcome, 'empty');
|
||||
assert.deepEqual(
|
||||
layer.getUIState().street.armed?.point,
|
||||
{ lat: 38.5808, lon: -121.494 },
|
||||
'still armed for the OPEN press',
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
|
||||
const street = layer.getUIState().street;
|
||||
assert.equal(street.nearestOutcome, null);
|
||||
assert.equal(street.error, 'Mapillary graph HTTP 500');
|
||||
});
|
||||
@@ -82,6 +82,12 @@ export const SEQUENCE_IMAGE_FIELDS =
|
||||
* the imagery filter then choose among the spot's photos, not a few of them.
|
||||
*/
|
||||
export const NEAREST_LIMIT = 100;
|
||||
/**
|
||||
* A nearest-image search that has not answered (headers and body) in this
|
||||
* long is given up: a link or a posted view waits on it, and so does every
|
||||
* view queued behind that one.
|
||||
*/
|
||||
export const NEAREST_TIMEOUT_MS = 12_000;
|
||||
/** Fields a nearest-image search needs: where each is, and the filter's. */
|
||||
export const NEAREST_IMAGE_FIELDS =
|
||||
'id,captured_at,geometry,computed_geometry,is_pano';
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
MAPILLARY_GRAPH_HOST,
|
||||
NEAREST_IMAGE_FIELDS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_TIMEOUT_MS,
|
||||
SEQUENCE_IMAGE_FIELDS,
|
||||
SEQUENCE_IMAGES_LIMIT,
|
||||
} from './policy.js';
|
||||
@@ -69,7 +70,47 @@ export function createMapillarySource({
|
||||
...endpoints,
|
||||
};
|
||||
|
||||
async function graph(path, params = {}, { signal } = {}) {
|
||||
async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) {
|
||||
if (!timeoutMs) return graphOnce(path, params, { signal });
|
||||
// The whole answer, body included, within `timeoutMs`; the caller's
|
||||
// abort still wins.
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
new MapillarySourceError('Mapillary did not answer in time', {
|
||||
status: 504,
|
||||
}),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
// A body that never ends is given up too, whatever the fetch does.
|
||||
const gaveUp = new Promise((_, reject) =>
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(controller.signal.reason),
|
||||
{ once: true },
|
||||
),
|
||||
);
|
||||
return await Promise.race([
|
||||
graphOnce(path, params, { signal: controller.signal }),
|
||||
gaveUp,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function graphOnce(path, params, { signal }) {
|
||||
if (graphBase) {
|
||||
const search = new URLSearchParams(params);
|
||||
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
|
||||
@@ -165,7 +206,7 @@ export function createMapillarySource({
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
fields: NEAREST_IMAGE_FIELDS,
|
||||
},
|
||||
{ signal },
|
||||
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
|
||||
);
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
|
||||
@@ -179,3 +179,44 @@ test('Graph endpoint errors keep their status', async () => {
|
||||
(error) => error.status === 503 && error.keyRequired === true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a nearest-image search that never answers is given up, body included', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
|
||||
// Headers arrive; the body never ends, and the fetch ignores its signal.
|
||||
const { fetchImpl } = fakeFetch(() => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => new Promise(() => {}),
|
||||
}));
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||
const pending = source.nearestImages({ lat: 1, lon: 2 });
|
||||
const outcome = pending.then(
|
||||
() => 'answered',
|
||||
(error) => error,
|
||||
);
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
const error = await outcome;
|
||||
assert.ok(error instanceof MapillarySourceError, String(error));
|
||||
assert.equal(error.status, 504);
|
||||
assert.equal(error.keyRequired, false, 'not a key problem');
|
||||
});
|
||||
|
||||
test('the caller’s abort still ends a nearest-image search before its deadline', async () => {
|
||||
const { fetchImpl } = fakeFetch(
|
||||
(_url, init) =>
|
||||
new Promise((_, reject) =>
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason)),
|
||||
),
|
||||
);
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||
const controller = new AbortController();
|
||||
const pending = source.nearestImages(
|
||||
{ lat: 1, lon: 2 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const reason = new Error('the turn moved on');
|
||||
controller.abort(reason);
|
||||
await assert.rejects(pending, (error) => error === reason);
|
||||
});
|
||||
@@ -19,6 +19,8 @@ export function freshStreet() {
|
||||
capturedAt: null,
|
||||
/** 'day', or 'month' for providers that date images by month (Google). */
|
||||
capturedAtPrecision: 'day',
|
||||
/** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */
|
||||
nearestOutcome: null,
|
||||
/** A place name for the caption (Google's address), or null. */
|
||||
title: null,
|
||||
/** Whether the open viewer has FIT/FILL render modes. */
|
||||
|
||||
@@ -123,6 +123,15 @@ test('embed mode shows an open photo over the globe, filling the overlay', () =>
|
||||
assert.ok(overlay, 'embed styles the viewer it moves over the globe');
|
||||
for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000'])
|
||||
assert.ok(overlay[1].includes(rule), rule);
|
||||
// Cockpit, recording and scene playback own the screen: the photo hides.
|
||||
for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode'])
|
||||
assert.match(
|
||||
css,
|
||||
new RegExp(
|
||||
`body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`,
|
||||
),
|
||||
mode,
|
||||
);
|
||||
// The photo takes the overlay's height, as it does full screen.
|
||||
assert.match(
|
||||
css,
|
||||
|
||||
@@ -1222,6 +1222,8 @@ export class StyleManager extends ShellFacade {
|
||||
revealPanel(panelId) {
|
||||
const sheet = sheetForPanelId(panelId);
|
||||
if (sheet) this._mobileShell?.setSheet(sheet);
|
||||
// The sheet opens at its top; a panel lower in the rail is scrolled to.
|
||||
document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
|
||||
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
|
||||
const sheets = [];
|
||||
const scrolls = [];
|
||||
const panel = { scrollIntoView: (options) => scrolls.push(options) };
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
|
||||
};
|
||||
try {
|
||||
StyleManager.prototype.revealPanel.call(
|
||||
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
|
||||
'street-level-panel',
|
||||
);
|
||||
} finally {
|
||||
globalThis.document = saved;
|
||||
}
|
||||
assert.deepEqual(sheets, ['cameras']);
|
||||
// The panel sits below the sheet's first screen at 375×812.
|
||||
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
|
||||
});
|
||||
@@ -285,8 +285,16 @@ export class StreetLevelControls {
|
||||
this._modesObserver = null;
|
||||
return;
|
||||
}
|
||||
// Embed mode keeps Clean View on for its globe while the photo is lifted
|
||||
// over it: there only recording and the cockpit end EXPAND.
|
||||
const leaveIfHiding = () => {
|
||||
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
|
||||
if (
|
||||
PANEL_HIDING_MODES.some(
|
||||
(mode) =>
|
||||
body.classList.contains(mode) &&
|
||||
!(mode === 'ui-clean-view' && isEmbedPage()),
|
||||
)
|
||||
)
|
||||
this._exitExpanded();
|
||||
};
|
||||
if (!this._modesObserver && typeof MutationObserver === 'function') {
|
||||
|
||||
@@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
|
||||
}
|
||||
}));
|
||||
|
||||
test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () =>
|
||||
withDom(async (dom) => {
|
||||
const saved = globalThis.MutationObserver;
|
||||
const watch = fakeMutationObserver();
|
||||
globalThis.MutationObserver = watch.FakeObserver;
|
||||
try {
|
||||
dom.document.body.classList.add('ui-embed');
|
||||
dom.document.body.classList.add('ui-clean-view');
|
||||
const { layer, controls } = await stubPanel(
|
||||
dom,
|
||||
uiState({ open: false }),
|
||||
);
|
||||
await layer.publish(uiState({ open: true }));
|
||||
dom.document.querySelector('#sl-viewer-expand').click();
|
||||
await settle();
|
||||
await settle();
|
||||
watch.fire();
|
||||
await settle();
|
||||
assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open');
|
||||
dom.document.body.classList.toggle('cockpit-mode', true);
|
||||
watch.fire();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(controls.isViewerExpanded(), false);
|
||||
controls.destroy();
|
||||
} finally {
|
||||
globalThis.MutationObserver = saved;
|
||||
}
|
||||
}));
|
||||
|
||||
/* ── Restores and keyboard rules ───────────────────────────────────────── */
|
||||
|
||||
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
|
||||
|
||||
@@ -22,6 +22,14 @@ body.ui-embed .sl-viewer-wrap-embed {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Cockpit, recording and scene playback own the screen, as they hide the
|
||||
panel elsewhere: the lifted photo steps out of their way. */
|
||||
body.ui-embed.cockpit-mode .sl-viewer-wrap-embed,
|
||||
body.ui-embed.recording-mode .sl-viewer-wrap-embed,
|
||||
body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage,
|
||||
body.ui-embed .sl-viewer-wrap-embed .sl-viewer {
|
||||
flex: 1 1 auto;
|
||||
|
||||
+45
-5
@@ -465,7 +465,7 @@ export function createGevActionRunner({
|
||||
const pending = dataManager.getAll?.().find((l) => l.id === layerId);
|
||||
reportProgress('layer', pending?.name || layerId);
|
||||
}
|
||||
const changeOptions = { origin: 'voice' };
|
||||
const changeOptions = { origin: actionIntentOrigin(runOptions) };
|
||||
if (runOptions.signal) changeOptions.signal = runOptions.signal;
|
||||
let changed = false;
|
||||
let changeError = null;
|
||||
@@ -3446,6 +3446,18 @@ function normalizeStyle(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The application panel that holds the street-level photo. */
|
||||
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
|
||||
|
||||
/**
|
||||
* The layer-state origin of an action's changes. The assistant's are the
|
||||
* user's own choices and persist; a link replays someone else's view
|
||||
* (`origin: 'programmatic'`) and never writes the recipient's preferences.
|
||||
*/
|
||||
function actionIntentOrigin(runOptions) {
|
||||
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
|
||||
}
|
||||
|
||||
/**
|
||||
* "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
|
||||
@@ -3482,8 +3494,10 @@ export async function showStreetLevel(
|
||||
...(provider ? { provider } : {}),
|
||||
...(providerName ? { providerName } : {}),
|
||||
...(label ? { label } : {}),
|
||||
...(atViewCentre ? { atViewCentre } : {}),
|
||||
...extra,
|
||||
});
|
||||
let atViewCentre = false;
|
||||
const query = String(args.query || '').trim();
|
||||
const latitude = Number(args.latitude);
|
||||
const longitude = Number(args.longitude);
|
||||
@@ -3540,7 +3554,7 @@ export async function showStreetLevel(
|
||||
(entity ? String(entity.label || entity.id) : '') ||
|
||||
(hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') ||
|
||||
'the view centre';
|
||||
const atViewCentre = !query && !hasCoordinates && !entity;
|
||||
atViewCentre = !query && !hasCoordinates && !entity;
|
||||
let point = null;
|
||||
let location = null;
|
||||
if (args.fly === false || atViewCentre) {
|
||||
@@ -3564,7 +3578,8 @@ export async function showStreetLevel(
|
||||
point = { lat: place.lat, lon: place.lng };
|
||||
label = place.label || query;
|
||||
}
|
||||
// No place at all: the layer looks under the view centre.
|
||||
// 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',
|
||||
@@ -3646,7 +3661,7 @@ export async function showStreetLevel(
|
||||
if (typeof dataManager.setLayerParams === 'function')
|
||||
accepted =
|
||||
dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, {
|
||||
origin: 'voice',
|
||||
origin: actionIntentOrigin(runOptions),
|
||||
}) !== false;
|
||||
else layerModule.setProviderEnabled?.(provider, true);
|
||||
if (!accepted)
|
||||
@@ -3712,6 +3727,9 @@ export async function showStreetLevel(
|
||||
const opened = await layerModule.openNearest(point, {
|
||||
providerIds: [provider],
|
||||
frame: false,
|
||||
// Under the screen centre, not the middle of a tilted view's footprint;
|
||||
// this request is the user's own, not a link's armed one.
|
||||
...(point ? {} : { aim: 'screen-centre', armed: false }),
|
||||
...(runOptions.signal ? { signal: runOptions.signal } : {}),
|
||||
...(snapshot.groundClick === true
|
||||
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
|
||||
@@ -3744,6 +3762,19 @@ export async function showStreetLevel(
|
||||
street.error || 'Street Level changed while it looked for imagery',
|
||||
{ cancelled: true, location },
|
||||
);
|
||||
// The screen centre is sky: nothing was looked up.
|
||||
if (street.nearestOutcome === 'sky')
|
||||
return fail('imagery', street.error || 'Aim the view at a street', {
|
||||
location,
|
||||
aim: 'sky',
|
||||
});
|
||||
// The source or the viewer failed: an error, not missing coverage.
|
||||
if (street.nearestOutcome !== 'empty')
|
||||
return fail(
|
||||
'imagery',
|
||||
street.error || `${providerName} imagery could not be opened`,
|
||||
{ location, imageryError: true },
|
||||
);
|
||||
// An imagery filter the source honours can hide every photo here; say
|
||||
// so, or the answer reads as "no coverage" when one switch would do.
|
||||
const filter = activeImageryFilter(layerModule.getUIState?.());
|
||||
@@ -3751,9 +3782,17 @@ export async function showStreetLevel(
|
||||
return fail(
|
||||
'imagery',
|
||||
filter ? `${reason} with the ${filter.label} on` : reason,
|
||||
{ location, ...(filter ? { filter } : {}) },
|
||||
{ location, noImagery: true, ...(filter ? { filter } : {}) },
|
||||
);
|
||||
}
|
||||
// Every explicit request shows its photo, also one replacing a photo whose
|
||||
// panel or phone sheet the user had closed. Embed mode lifts the photo.
|
||||
if (!globalThis.document?.body?.classList?.contains('ui-embed')) {
|
||||
styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
persist: false,
|
||||
});
|
||||
styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action,
|
||||
@@ -3767,6 +3806,7 @@ export async function showStreetLevel(
|
||||
position: street.position ?? null,
|
||||
externalUrl: street.externalUrl ?? null,
|
||||
flew: location !== null,
|
||||
...(atViewCentre ? { atViewCentre } : {}),
|
||||
...(location ? { location: location.label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5715,8 +5715,13 @@ function createStreetLevelHarness({
|
||||
duringLookup = null,
|
||||
keyRejectedAtStart = false,
|
||||
lightFails = false,
|
||||
imageryError = null,
|
||||
sky = false,
|
||||
} = {}) {
|
||||
const order = [];
|
||||
const lookups = [];
|
||||
const origins = [];
|
||||
const revealed = [];
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
let completeFlight = null;
|
||||
viewer.camera.lookAt = () => {};
|
||||
@@ -5776,6 +5781,7 @@ function createStreetLevelHarness({
|
||||
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
|
||||
},
|
||||
async openNearest(point, options) {
|
||||
lookups.push({ point, ...options });
|
||||
order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`);
|
||||
if (options.signal?.aborted) return false;
|
||||
if (duringLookup) {
|
||||
@@ -5790,7 +5796,13 @@ function createStreetLevelHarness({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (imageryError) {
|
||||
street.error = imageryError;
|
||||
street.nearestOutcome = null;
|
||||
return false;
|
||||
}
|
||||
if (!nearest) {
|
||||
street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty';
|
||||
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
return false;
|
||||
}
|
||||
@@ -5805,12 +5817,14 @@ function createStreetLevelHarness({
|
||||
isEnabled: () => enabled,
|
||||
getLayerParams: () => ({ ...params }),
|
||||
setLayerParams(id, next, { origin }) {
|
||||
origins.push(origin);
|
||||
order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`);
|
||||
if (paramsAccepted === false) return false;
|
||||
if (!lightFails) params = { ...params, ...next };
|
||||
return true;
|
||||
},
|
||||
async setEnabled(id) {
|
||||
async setEnabled(id, _on, options) {
|
||||
origins.push(options?.origin);
|
||||
order.push(`enable:${id}`);
|
||||
if (enableResult === false) return false;
|
||||
enabled = true;
|
||||
@@ -5818,8 +5832,10 @@ function createStreetLevelHarness({
|
||||
},
|
||||
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
|
||||
};
|
||||
styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`);
|
||||
styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`);
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
|
||||
return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager };
|
||||
return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager, lookups, origins, revealed };
|
||||
}
|
||||
|
||||
/** A loaded CCTV camera the entity branch can locate. */
|
||||
@@ -6403,3 +6419,65 @@ test('show_street_level opens over the clean embedded globe but respects its oth
|
||||
globalThis.document = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const link = createStreetLevelHarness();
|
||||
const linked = await link.runner(
|
||||
'show_street_level',
|
||||
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
|
||||
{ origin: 'programmatic' },
|
||||
);
|
||||
assert.equal(linked.ok, true, linked.error);
|
||||
assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on');
|
||||
const asked = createStreetLevelHarness();
|
||||
await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice');
|
||||
});
|
||||
|
||||
test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const here = createStreetLevelHarness({ enabled: true });
|
||||
const result = await here.runner('show_street_level', { provider: 'mapillary' });
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal(result.atViewCentre, true);
|
||||
assert.equal(here.lookups[0].point, null);
|
||||
assert.equal(here.lookups[0].aim, 'screen-centre');
|
||||
assert.equal(here.lookups[0].armed, false);
|
||||
const placed = createStreetLevelHarness({ enabled: true });
|
||||
await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
|
||||
assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is');
|
||||
const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true });
|
||||
const refused = await sky.runner('show_street_level', { provider: 'mapillary' });
|
||||
assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']);
|
||||
});
|
||||
|
||||
test('show_street_level tells missing imagery from a failing source', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', {
|
||||
latitude: 1, longitude: 2, provider: 'mapillary', fly: false,
|
||||
});
|
||||
assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]);
|
||||
const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner(
|
||||
'show_street_level',
|
||||
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
|
||||
);
|
||||
assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]);
|
||||
assert.equal(broken.error, 'Mapillary graph HTTP 500');
|
||||
});
|
||||
|
||||
test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const harness = createStreetLevelHarness({ enabled: true });
|
||||
await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
|
||||
await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual(harness.revealed, [
|
||||
'collapse:street-level-panel:false',
|
||||
'reveal:street-level-panel',
|
||||
'collapse:street-level-panel:false',
|
||||
'reveal:street-level-panel',
|
||||
]);
|
||||
const missing = createStreetLevelHarness({ enabled: true, nearest: null });
|
||||
await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed');
|
||||
});
|
||||
+13
-4
@@ -626,7 +626,11 @@ function showStreetLevel(result) {
|
||||
const shownSource = result?.providerName
|
||||
? spokenLabel(result.providerName)
|
||||
: t('Street Level');
|
||||
const shown = spokenLabel(localizePlaceName(result?.label || '')) || place;
|
||||
// The view centre is a phrase, not a place name: the card says it in the
|
||||
// interface language.
|
||||
const shown = result?.atViewCentre
|
||||
? t('the view centre')
|
||||
: spokenLabel(localizePlaceName(result?.label || '')) || place;
|
||||
if (result?.cancelled) return null;
|
||||
if (!result?.ok) {
|
||||
let say;
|
||||
@@ -640,10 +644,15 @@ function showStreetLevel(result) {
|
||||
else if (result?.stage === 'layer') say = "Couldn't turn on Street Level.";
|
||||
else if (result?.stage === 'view')
|
||||
say = 'Street-level photos are hidden in this view. Leave it first.';
|
||||
else if (result?.stage === 'imagery' && result?.aim === 'sky')
|
||||
say = 'Aim the middle of the view at a street first.';
|
||||
else if (result?.stage === 'imagery' && result?.filter?.label)
|
||||
say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`;
|
||||
else if (result?.stage === 'imagery' && result?.noImagery === true)
|
||||
say = `No ${source} imagery near ${place}.`;
|
||||
// A service or viewer failure is not missing coverage.
|
||||
else if (result?.stage === 'imagery')
|
||||
say = result?.filter?.label
|
||||
? `No ${source} imagery near ${place} with the ${result.filter.label} on.`
|
||||
: `No ${source} imagery near ${place}.`;
|
||||
say = `${source} could not open imagery near ${place}.`;
|
||||
else say = `Couldn't open ${source}.`;
|
||||
return {
|
||||
say,
|
||||
|
||||
@@ -536,6 +536,22 @@ test('labels are bounded and cleaned before they are spoken', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the street-level card says "the view centre" in the interface language', () => {
|
||||
setLocale('de');
|
||||
try {
|
||||
const out = envelope('show_street_level', {
|
||||
ok: true,
|
||||
providerName: 'Mapillary',
|
||||
label: 'the view centre',
|
||||
atViewCentre: true,
|
||||
});
|
||||
assert.equal(out.display.lines[0], 'die Bildmitte');
|
||||
assert.match(out.say, /the view centre/, 'speech stays English');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
|
||||
test('street-level speech names the source and the place, and says which step failed', () => {
|
||||
const ok = envelope('show_street_level', {
|
||||
ok: true,
|
||||
@@ -556,7 +572,8 @@ test('street-level speech names the source and the place, and says which step fa
|
||||
map: "Google Street View needs the Google 3D map, which isn't available.",
|
||||
key: 'Google Street View needs its key.',
|
||||
layer: "Couldn't turn on Street Level.",
|
||||
imagery: 'No Google Street View imagery near Nowhere.',
|
||||
// A service or viewer failure, not missing coverage.
|
||||
imagery: 'Google Street View could not open imagery near Nowhere.',
|
||||
};
|
||||
for (const [stage, say] of Object.entries(stages)) {
|
||||
const out = envelope('show_street_level', {
|
||||
@@ -570,6 +587,24 @@ test('street-level speech names the source and the place, and says which step fa
|
||||
assertSpeakable(out.say);
|
||||
assert.deepEqual(out.display.lines, ['detail']);
|
||||
}
|
||||
const empty = envelope('show_street_level', {
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
noImagery: true,
|
||||
label: 'Nowhere',
|
||||
providerName: 'Mapillary',
|
||||
});
|
||||
assert.equal(empty.say, 'No Mapillary imagery near Nowhere.');
|
||||
const sky = envelope('show_street_level', {
|
||||
ok: false,
|
||||
stage: 'imagery',
|
||||
aim: 'sky',
|
||||
atViewCentre: true,
|
||||
label: 'the view centre',
|
||||
providerName: 'Mapillary',
|
||||
});
|
||||
assert.equal(sky.say, 'Aim the middle of the view at a street first.');
|
||||
assertSpeakable(sky.say);
|
||||
const armed = envelope('show_street_level', {
|
||||
ok: true,
|
||||
armed: true,
|
||||
|
||||
Reference in new issue
Block a user