fix(street-level): re-check after every await; close the old photo; fresh panel views

Address samehkhamis's review on #1113:

- show_street_level checks the turn, the camera's owner (navigation stamp,
  re-taken where its own flight lands, or a press/wheel on the globe) and
  the hiding modes after the flight, the map switch, the layer switch-on and
  the lookup; a photo that opened after either is closed again. The hidden
  panel is also refused before the flight.
- An open photo closes before a new request arms or looks up, so a place
  with no imagery shows its own error.
- The panel posts every view with `fresh`; the inline app opens a billed
  photo only for a fresh one.
- A passed-through view's street_level point moves with a new camera
  position; revealPanel does nothing above 720px, scrolling included.
- Document that the app's own links do not carry `sl`.
This commit is contained in:
Said Turksever committed 2026-11-06 00:40:20 +00:00
1 parent 14f7326dbf
commit 30e6cd39c7
15 files changed
+486 -57

No files matched your search

+19 -3
View File
@@ -216,7 +216,14 @@ function armedToast() {
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
{
viewer,
dataManager,
run,
signal,
autoOpen = true,
retryMs = FOLLOW_RETRY_MS,
},
) {
const steps = [];
const act = async (name, args) => {
@@ -258,7 +265,7 @@ export async function applyView(
// before flying, the view's own camera still applies.
const result = await act(
'show_street_level',
streetLevelArgs(view.street_level),
streetLevelArgs(view.street_level, { autoOpen }),
);
if (result?.ok !== true) {
// An earlier view's photo must not stay over a place this view left.
@@ -447,9 +454,18 @@ export function installViews({
);
return;
}
// A panel's host can hand back old results on a remount: only one issued
// just now may open a billed photo. A framing page's view is its own.
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
const steps = await applyView(view, {
viewer,
dataManager,
run,
signal,
autoOpen,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
+53
View File
@@ -188,6 +188,59 @@ test('an embedded page takes views only from its parent and answers it', async (
remove();
});
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = () => {};
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
const { calls, run } = recorder();
globalThis.GEV_EMBED_INLINE = true;
try {
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: '' },
windowRef,
});
const view = {
camera: { lat: 48.8584, lon: 2.2945 },
map: 'photoreal',
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
};
for (const [id, fresh] of [
[1, true],
[2, false],
[3, undefined],
]) {
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id, view, fresh },
});
windowRef.dispatchEvent(event);
}
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(
calls
.filter(([name]) => name === 'show_street_level')
.map(([, args]) => args.autoOpen ?? true),
[true, false, false],
);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
+16 -9
View File
@@ -560,9 +560,15 @@ export function panelRuntime(config) {
let started = false;
let ready = false;
let queued = null;
let queuedFresh = false;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
// Every view carries whether its result was issued just now: only those may
// open a billed photo, whichever result on this mount they came in.
const postView = (view, fresh) =>
window.postMessage(
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
'*',
);
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
@@ -570,16 +576,17 @@ export function panelRuntime(config) {
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
const issuedAt = Number(data.issued_at);
const fresh =
Number.isFinite(issuedAt) &&
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
if (ready) return postView(view, fresh);
queued = view;
queuedFresh = fresh;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view, fresh).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
@@ -601,7 +608,7 @@ export function panelRuntime(config) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
if (queued) postView(queued, queuedFresh);
queued = null;
}
return;
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { panelRuntime } from './globePanelRuntime.js';
const element = () => ({
isConnected: true,
hidden: true,
textContent: '',
addEventListener() {},
remove() {
this.isConnected = false;
},
});
test('every view the panel posts says whether its result was issued just now', async () => {
const savedWindow = globalThis.window;
const savedDocument = globalThis.document;
const views = [];
const parent = { postMessage() {} };
const windowRef = new EventTarget();
Object.assign(windowRef, {
parent,
postMessage: (message) => {
if (message?.type === 'gev:view') views.push(message);
},
});
const elements = { status: element(), open: element(), expand: element() };
globalThis.window = windowRef;
globalThis.document = {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
baseURI: 'https://host.example/panel',
body: { clientWidth: 600 },
};
const deliver = (source, data) => {
const event = new Event('message');
Object.assign(event, { source, data });
windowRef.dispatchEvent(event);
};
const result = (issuedAt) => ({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: {
structuredContent: {
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
issued_at: issuedAt,
view: { camera: { lat: 48.8584, lon: 2.2945 } },
},
},
});
try {
panelRuntime({
appBaseUrl: 'http://localhost:4173/',
panelBase: '/panel/',
protocolVersion: '2025-06-18',
panelHeight: 480,
});
deliver(windowRef, { type: 'gev:ready' });
deliver(parent, result(Date.now()));
// A host replaying an old result on the same mount.
deliver(parent, result(Date.now() - 60 * 60 * 1000));
deliver(parent, result(undefined));
} finally {
globalThis.window = savedWindow;
globalThis.document = savedDocument;
}
assert.deepEqual(
views.map((message) => message.fresh),
[true, false, false],
);
});
+7 -2
View File
@@ -77,12 +77,17 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
: { ...base.camera, ...args.camera };
let label = null;
// Street-level photos open at the area's centre unless a point is given;
// a new area moves a passed-through view's photos with it.
// a new area or camera position moves a passed-through view's photos with
// it.
const moved =
Boolean(args.area) ||
args.camera?.lat !== undefined ||
args.camera?.lon !== undefined;
let streetLevel =
args.street_level && typeof args.street_level === 'object'
? { ...args.street_level }
: base.street_level && typeof base.street_level === 'object'
? args.area
? moved
? { provider: base.street_level.provider }
: { ...base.street_level }
: null;
+16 -1
View File
@@ -158,7 +158,7 @@ test('links can open street-level photos at the area, on the map the source need
);
});
test('a new area moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
test('a new area or camera position moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
@@ -176,6 +176,21 @@ test('a new area moves a passed-through view’s photos, and a tilted area still
lat: 42.3314,
lon: -83.0458,
});
// A new camera position moves them too; turning the camera keeps them.
const moved = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { lat: 40.758, lon: -73.9855, altitude_m: 800, pitch_deg: -90 },
});
assert.deepEqual(moved.data.view.street_level, {
provider: 'google',
lat: 40.758,
lon: -73.9855,
});
const turned = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { heading_deg: 90 },
});
assert.deepEqual(turned.data.view.street_level, first.data.view.street_level);
const tilted = await catalog.call('show_in_gods_eye_view', {
area: { lat: 48.8584, lon: 2.2945, radius_km: 1 },
camera: { pitch_deg: -30 },
+6 -1
View File
@@ -4,7 +4,11 @@ import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
import { PanelChrome } from './panelChrome.js';
import { initMobileShell, sheetForPanelId } from './mobileShell.js';
import {
initMobileShell,
MOBILE_SHELL_MEDIA_QUERY,
sheetForPanelId,
} from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
@@ -1221,6 +1225,7 @@ export class StyleManager extends ShellFacade {
*/
revealPanel(panelId) {
const sheet = sheetForPanelId(panelId);
if (!globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches) return;
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' });
+15 -1
View File
@@ -2,14 +2,18 @@ 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', () => {
function reveal({ phone }) {
const sheets = [];
const scrolls = [];
const panel = { scrollIntoView: (options) => scrolls.push(options) };
const saved = globalThis.document;
const savedMatchMedia = globalThis.matchMedia;
globalThis.document = {
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
};
globalThis.matchMedia = (query) => ({
matches: phone && query === '(max-width: 720px)',
});
try {
StyleManager.prototype.revealPanel.call(
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
@@ -17,8 +21,18 @@ test('revealing a panel opens its phone sheet and scrolls the panel into view',
);
} finally {
globalThis.document = saved;
globalThis.matchMedia = savedMatchMedia;
}
return { sheets, scrolls };
}
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
const { sheets, scrolls } = reveal({ phone: true });
assert.deepEqual(sheets, ['cameras']);
// The panel sits below the sheet's first screen at 375×812.
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
});
test('revealing a panel above 720px neither opens a sheet nor scrolls the rail', () => {
assert.deepEqual(reveal({ phone: false }), { sheets: [], scrolls: [] });
});
+116 -28
View File
@@ -760,6 +760,7 @@ export function createGevActionRunner({
if (name === 'show_street_level') {
return showStreetLevel(args, {
viewer,
dataManager,
styleManager,
placeSearch,
@@ -3458,6 +3459,55 @@ function actionIntentOrigin(runOptions) {
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
}
/** Gestures on the globe that take the camera from a pending request. */
const CAMERA_GESTURES = ['pointerdown', 'wheel'];
/**
* Whether the camera is still a request's: a navigation the shell stamps (a
* search, the assistant, a link) or a press or wheel on the globe hands it to
* someone else. `claim()` makes the camera as it is now the request's (after
* its own flight); `stop()` removes the gesture listeners.
*/
function watchCameraOwner(viewer, navigation) {
let generation = navigation?._navigationGeneration;
const canvas = viewer?.canvas ?? viewer?.scene?.canvas ?? null;
let taken = false;
const onGesture = () => {
taken = true;
};
for (const type of CAMERA_GESTURES)
canvas?.addEventListener?.(type, onGesture, { passive: true });
return {
claim: () => {
generation = navigation?._navigationGeneration;
taken = false;
},
taken: () =>
taken ||
(Number.isFinite(generation) &&
navigation._navigationGeneration !== generation),
stop: () => {
for (const type of CAMERA_GESTURES)
canvas?.removeEventListener?.(type, onGesture);
},
};
}
/**
* Whether cockpit, clean view, recording or scene playback hides the panel a
* street-level photo opens in. Embed mode uses clean view for its globe but
* lifts the photo out of the hidden rail.
*/
function streetLevelPanelHidden() {
const body = globalThis.document?.body;
const embedded = body?.classList?.contains('ui-embed') === true;
return EXCLUSIVE_SURFACE_CLASSES.some(
(mode) =>
body?.classList?.contains(mode) &&
!(embedded && mode === 'ui-clean-view'),
);
}
/**
* "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
@@ -3469,13 +3519,26 @@ function actionIntentOrigin(runOptions) {
* from a source that bills every load: the panel is armed and the OPEN
* button is one click away.
*/
export async function showStreetLevel(
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);
try {
return await requestStreetLevel(args, { ...context, owner });
} finally {
owner.stop();
}
}
async function requestStreetLevel(
args,
{
dataManager,
styleManager,
placeSearch,
runGevAction,
owner,
runOptions = {},
current = () => true,
reportProgress = () => {},
@@ -3548,6 +3611,34 @@ export async function showStreetLevel(
keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [],
});
// A hidden panel refuses before the camera leaves a recording or scene.
const hidden = (extra = {}) =>
fail(
'view',
msg(
'Street-level photos are hidden in the current view. Leave it first.',
),
extra,
);
if (streetLevelPanelHidden()) return hidden();
const interrupted = (stage, extra = {}) => {
if (!current())
return fail(stage, 'Street-level request was cancelled', {
cancelled: true,
location,
...extra,
});
if (owner.taken())
return fail(stage, 'The camera moved on before the photo opened', {
cancelled: true,
superseded: true,
location,
...extra,
});
if (streetLevelPanelHidden()) return hidden({ location, ...extra });
return null;
};
// Then the place: a name that geocodes to nothing changes nothing else.
label =
query ||
@@ -3615,24 +3706,11 @@ export async function showStreetLevel(
}
// Cockpit, clean view, recording and scene playback hide the panel the
// photo opens in: say so before anything loads unseen. Embed mode uses
// clean view for its globe but lifts the photo out of the hidden rail.
const body = globalThis.document?.body;
const embedded = body?.classList?.contains('ui-embed') === true;
if (
EXCLUSIVE_SURFACE_CLASSES.some(
(mode) =>
body?.classList?.contains(mode) &&
!(embedded && mode === 'ui-clean-view'),
)
)
return fail(
'view',
msg(
'Street-level photos are hidden in the current view. Leave it first.',
),
{ location },
);
// photo opens in, and may have started during the flight.
// The camera is the request's from where its own flight landed.
if (location) owner.claim();
const arrived = interrupted('view');
if (arrived) return arrived;
// The source's map, when it needs one (Street View: Google 3D only).
const requiredStack = layerModule.requiredMapStack?.(provider) || null;
@@ -3644,13 +3722,15 @@ export async function showStreetLevel(
{ stack: requiredStack },
runOptions,
);
if (map?.ok !== true || !current())
if (map?.ok !== true && current())
return fail(
'map',
map?.error ||
`${providerName} needs the ${requiredStack} map, which is not available`,
{ map, location },
);
const stop = interrupted('map', { map });
if (stop) return stop;
}
// Light the source the way a chip click does, so the choice is kept.
@@ -3675,12 +3755,14 @@ export async function showStreetLevel(
{ layerId: STREET_LEVEL_LAYER_ID, enabled: true },
runOptions,
);
if (layer?.ok !== true || !current())
if (layer?.ok !== true && current())
return fail(
'layer',
layer?.error || 'Street Level could not be enabled',
{ cancelled: !current() || Boolean(layer?.cancelled), layer, location },
{ cancelled: Boolean(layer?.cancelled), layer, location },
);
const stop = interrupted('layer', { layer });
if (stop) return stop;
}
// Listed now that its map is up, and lit.
@@ -3698,6 +3780,11 @@ export async function showStreetLevel(
location,
});
// A photo of another place must not stay up beside this request's answer,
// armed or opened, found or not: the panel shows this request's outcome.
if (layerModule.getUIState?.()?.street?.open === true)
layerModule.closeViewer?.();
// A billed source never loads from a link without a click: everything is
// ready and OPEN is one press away at the view centre.
if (args.autoOpen === false && status?.billed === true) {
@@ -3737,12 +3824,13 @@ export async function showStreetLevel(
});
const street = layerModule.getUIState?.()?.street || {};
// A cancelled request opens nothing (the lookup and the viewer both honour
// the turn's signal); a photo that did open is reported as open.
if (opened !== true && !current())
return fail('imagery', 'Street-level request was cancelled', {
cancelled: true,
location,
});
// the turn's signal). A photo that opened after the camera moved on, or
// into a panel hidden meanwhile, is closed again: nobody asked to see it.
const late = interrupted('imagery');
if (late) {
if (opened === true) layerModule.closeViewer?.();
return late;
}
if (opened !== true) {
// Why nothing opened: the key, a change under the lookup, or no imagery.
const after = layerModule.providerStatus?.(provider) || status || {};
+129 -3
View File
@@ -5717,12 +5717,21 @@ function createStreetLevelHarness({
lightFails = false,
imageryError = null,
sky = false,
photoOpen = false,
duringMapSwitch = null,
duringEnable = null,
} = {}) {
const order = [];
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager } = createVoiceNavigationHarness();
const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness();
// Who owns the camera: the shell's navigation stamps and globe gestures.
let userNavigations = 0;
Object.defineProperty(styleManager, '_navigationGeneration', {
get: () => currentGeneration() + userNavigations,
});
viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 });
let completeFlight = null;
viewer.camera.lookAt = () => {};
viewer.camera.flyToBoundingSphere = (_sphere, options) => {
@@ -5736,11 +5745,14 @@ function createStreetLevelHarness({
styleManager.setMapStack = async (id) => {
order.push(`map:${id}`);
stack = id;
duringMapSwitch?.();
return { ok: true, activeStack: id, error: null };
};
let params = { ...lit };
let keyRejected = keyRejectedAtStart;
const street = { imageId: null, error: null };
const street = photoOpen
? { open: true, imageId: 'eiffel-photo', error: null }
: { open: false, imageId: null, error: null };
const names = { google: 'Google Street View', mapillary: 'Mapillary' };
const layer = {
providerIds: ['mapillary', 'google'],
@@ -5777,6 +5789,10 @@ function createStreetLevelHarness({
coverage: { hint: 'not on this map' },
street: { ...street },
}),
closeViewer() {
order.push('close');
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
},
armNearest(point, options) {
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
},
@@ -5806,6 +5822,7 @@ function createStreetLevelHarness({
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false;
}
street.open = true;
street.imageId = nearest;
street.title = '10th St, Sacramento';
street.position = point;
@@ -5828,6 +5845,7 @@ function createStreetLevelHarness({
order.push(`enable:${id}`);
if (enableResult === false) return false;
enabled = true;
duringEnable?.();
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
@@ -5835,7 +5853,22 @@ function createStreetLevelHarness({
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, lookups, origins, revealed };
return {
runner,
order,
finishFlight: () => completeFlight?.(),
layer,
dataManager,
lookups,
origins,
revealed,
street,
// The user searches or flies elsewhere, or drags the globe.
navigate: () => {
userNavigations += 1;
},
gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')),
};
}
/** A loaded CCTV camera the entity branch can locate. */
@@ -6398,6 +6431,99 @@ test('show_street_level in a view that hides its panel stops before anything loa
}
});
test('show_street_level checks the panel again after every wait: a mode entered meanwhile loads nothing unseen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
const classes = new Set();
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
try {
for (const [mode, when] of [
['ui-clean-view', 'duringEnable'],
['cockpit-mode', 'duringMapSwitch'],
]) {
classes.clear();
const h = createStreetLevelHarness({ [when]: () => classes.add(mode) });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(h.order.filter((step) => /^(open|arm)/.test(step)), [], `${mode}: no billed load`);
assert.deepEqual(h.revealed, [], `${mode}: nothing revealed`);
}
// Entered while the photo loaded: it is closed again, not reported as shown.
classes.clear();
const late = createStreetLevelHarness({ enabled: true });
const open = late.layer.openNearest;
late.layer.openNearest = async (...call) => {
const opened = await open(...call);
classes.add('recording-mode');
return opened;
};
const result = await late.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.equal(late.order.at(-1), 'close');
assert.deepEqual(late.revealed, []);
} finally {
globalThis.document = saved;
}
});
test('show_street_level refuses a hidden panel before the camera leaves a recording', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
globalThis.document = { body: { classList: { contains: (name) => name === 'recording-mode' } } };
try {
const { runner, order } = createStreetLevelHarness();
const result = await runner('show_street_level', { query: 'Eiffel Tower', provider: 'mapillary' });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.deepEqual(order, [], 'no flight');
} finally {
globalThis.document = saved;
}
});
test('show_street_level stops once the camera is someone else’s: a navigation or a gesture during the map switch', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
for (const take of ['navigate', 'gesture']) {
let h;
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true, `${take}: Mapillary needs no map switch`);
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const google = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([google.ok, google.stage, google.cancelled, google.superseded], [false, 'map', true, true], take);
assert.deepEqual(h.order.filter((step) => /^(params|enable|open|arm)/.test(step)), [], `${take}: nothing lit for the old place`);
}
// During the layer switch-on: the photo lookup never runs.
let h;
h = createStreetLevelHarness({ duringEnable: () => h.navigate() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'layer', true]);
assert.ok(!h.order.some((step) => step.startsWith('open:')));
});
test('show_street_level owns the camera from where its own flight lands', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary' });
await new Promise((resolve) => setTimeout(resolve, 0));
h.finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.ok(h.order.some((step) => step.startsWith('open:mapillary')));
});
test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });
const result = await empty.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.noImagery], [false, true]);
assert.equal(empty.street.open, false, 'the Paris photo is gone');
assert.match(empty.street.error, /No street-level imagery/, 'the panel shows why');
// A link that only arms a billed source closes it too.
const armed = createStreetLevelHarness({ enabled: true, photoOpen: true, activeStack: 'photoreal' });
await armed.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'google', fly: false, autoOpen: false });
assert.deepEqual(armed.order.filter((step) => /^(close|arm)/.test(step)).map((step) => step.split(':')[0]), ['close', 'arm']);
});
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;