mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
- Google 3D: the OPEN button re-measures a ground whose tiles are still loading (a few times, a second apart) and on a map change, not only after the next camera move. - Mapillary: two mounts on one host during the first download share one build instead of leaking a second WebGL viewer. - Retired floating-panel keys (v8 panelPos, panelFloatHintShown) are dropped from localStorage; dead inline-position resets, the drag JSDoc and the dead qa-ui-disposal observer branch are gone. - Docs: z ladder, panel chrome, position storage and the fixture gate's actual coverage; changelog fragment for #974 (old .gev-cache/mapillary can be deleted). - ru/es: the layer is named as everywhere else in the UI. - Fixtures and test fakes: unused exports removed; a fake credit removal of an absent credit no longer drops another one; the tile test reset frees upstream slots. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1130 lines
41 KiB
JavaScript
1130 lines
41 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import * as Cesium from 'cesium';
|
||
import { createStreetLevelLayer } from './index.js';
|
||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
||
import {
|
||
fakeCesiumViewer,
|
||
fakeMapillarySource,
|
||
fakePhotoViewer,
|
||
fakeStreetLevelProvider as fakeProvider,
|
||
} from '../../testSupport/streetLevelFakes.mjs';
|
||
import { setLocale } from '../../i18n/index.js';
|
||
|
||
/**
|
||
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
|
||
* and (unless `enable` is false) enabled.
|
||
*/
|
||
async function startLayer(
|
||
t,
|
||
{
|
||
source = fakeMapillarySource(),
|
||
photoViewer = fakePhotoViewer(),
|
||
providers = [createMapillaryProvider({ source, photoViewer })],
|
||
view = false,
|
||
enable = true,
|
||
} = {},
|
||
) {
|
||
const saved = globalThis.document;
|
||
// The click handler listens for Esc on the document.
|
||
globalThis.document = new EventTarget();
|
||
// Skip Cesium's one-time terrain table download for draped lines.
|
||
t.mock.method(
|
||
Cesium.GroundPolylinePrimitive,
|
||
'initializeTerrainHeights',
|
||
async () => {},
|
||
);
|
||
const viewer = fakeCesiumViewer({ view });
|
||
const layer = createStreetLevelLayer({ providers });
|
||
layer.init(viewer);
|
||
if (enable) layer.enable(viewer);
|
||
t.after(() => {
|
||
layer.destroy();
|
||
globalThis.document = saved;
|
||
});
|
||
await settle();
|
||
return { layer, viewer, source, photoViewer };
|
||
}
|
||
|
||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
function deferred() {
|
||
let resolve;
|
||
let reject;
|
||
const promise = new Promise((done, fail) => {
|
||
resolve = done;
|
||
reject = fail;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
/** A photo viewer reporting a pose for every image it opens. */
|
||
const posingViewer = () =>
|
||
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
|
||
|
||
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||
const { layer, viewer } = await startLayer(t);
|
||
const shown = () => viewer.credits.map((credit) => credit.html);
|
||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
|
||
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
||
layer.setParams({ mapillary: false });
|
||
assert.deepEqual(shown(), [], 'hidden with the switch');
|
||
layer.setParams({ mapillary: true });
|
||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
||
layer.disable();
|
||
assert.deepEqual(shown(), [], 'the credit goes with the layer');
|
||
});
|
||
|
||
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||
for (const configured of [false, true]) {
|
||
const status = deferred();
|
||
const source = fakeMapillarySource({ getStatus: () => status.promise });
|
||
const { layer } = await startLayer(t, { source, view: true });
|
||
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
|
||
status.resolve({ configured });
|
||
await settle();
|
||
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
|
||
else {
|
||
assert.equal(
|
||
source.calls.tiles.length,
|
||
0,
|
||
'a key-less install never asks',
|
||
);
|
||
assert.deepEqual(
|
||
[layer.getStats().loadingLabel, layer.getStats().error],
|
||
['KEY REQUIRED', 'KEY REQUIRED'],
|
||
);
|
||
assert.equal(layer.getUIState().keyRequired, true);
|
||
assert.equal(layer.getUIState().keyRejected, false);
|
||
}
|
||
layer.destroy();
|
||
}
|
||
});
|
||
|
||
test('a key status that failed is asked again on the next switch-on', async (t) => {
|
||
let up = false;
|
||
let asked = 0;
|
||
const source = fakeMapillarySource({
|
||
hasToken: () => false,
|
||
getStatus: async () => {
|
||
asked++;
|
||
if (!up) throw new TypeError('fetch failed');
|
||
return { configured: true };
|
||
},
|
||
});
|
||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||
assert.equal(asked, 1, 'one check for init and the first switch-on');
|
||
assert.equal(layer.getUIState().keyRequired, true);
|
||
up = true; // the server is reachable again
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
await settle();
|
||
assert.equal(asked, 2);
|
||
assert.equal(layer.getUIState().keyRequired, false);
|
||
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
|
||
// A known key is not asked again.
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
await settle();
|
||
assert.equal(asked, 2);
|
||
});
|
||
|
||
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
|
||
// The check's timeout, fired when the test says.
|
||
const timeouts = [];
|
||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||
const controller = new AbortController();
|
||
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
|
||
return controller.signal;
|
||
});
|
||
let asked = 0;
|
||
const source = fakeMapillarySource({
|
||
hasToken: () => false,
|
||
getStatus: ({ signal } = {}) =>
|
||
new Promise((_, reject) => {
|
||
asked++;
|
||
signal?.addEventListener('abort', () => reject(signal.reason));
|
||
}),
|
||
});
|
||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||
assert.equal(asked, 1);
|
||
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
|
||
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
|
||
timeouts[0].fire();
|
||
await settle();
|
||
assert.equal(layer.getUIState().coverage.loading, false);
|
||
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
assert.equal(asked, 2, 'asked again, not stuck on the first');
|
||
});
|
||
|
||
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
|
||
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
|
||
keyRejected: true,
|
||
});
|
||
const source = fakeMapillarySource({
|
||
getTile: async () => {
|
||
throw rejected;
|
||
},
|
||
});
|
||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||
await settle();
|
||
const stats = layer.getStats();
|
||
assert.equal(stats.keyRequired, true);
|
||
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
||
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||
const ui = layer.getUIState();
|
||
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
|
||
});
|
||
|
||
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
||
const { layer, photoViewer } = await startLayer(t);
|
||
layer.attachViewerHost({});
|
||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
||
assert.equal(layer.getUIState().street.open, true);
|
||
layer.setParams({ mapillary: false });
|
||
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
||
assert.equal(layer.getUIState().street.open, false);
|
||
assert.equal(layer.getUIState().providers[0].on, false);
|
||
});
|
||
|
||
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
|
||
const source = fakeMapillarySource({
|
||
getSequenceImages: async () => {
|
||
throw new Error('Sequence images unavailable');
|
||
},
|
||
});
|
||
// The photo's pose names its sequence, which opening selects.
|
||
const photoViewer = fakePhotoViewer({
|
||
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
|
||
});
|
||
const { layer } = await startLayer(t, { source, photoViewer });
|
||
layer.attachViewerHost({});
|
||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||
await settle();
|
||
let ui = layer.getUIState();
|
||
assert.equal(ui.sequence.error, 'Sequence images unavailable');
|
||
assert.equal(ui.street.error, null, 'the photo itself is fine');
|
||
layer.closeViewer();
|
||
ui = layer.getUIState();
|
||
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
|
||
});
|
||
|
||
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
|
||
const { layer, viewer } = await startLayer(t, {
|
||
photoViewer: posingViewer(),
|
||
});
|
||
layer.attachViewerHost({});
|
||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||
layer.closeViewer();
|
||
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
||
|
||
await layer.openImage('mapillary', 'img2');
|
||
layer.disable();
|
||
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
||
|
||
layer.enable(viewer);
|
||
await layer.openImage('mapillary', 'img3');
|
||
layer.destroy();
|
||
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||
});
|
||
|
||
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||
const { layer } = await startLayer(t);
|
||
assert.deepEqual(layer.getParams(), {
|
||
mapillary: true,
|
||
pano: 'all',
|
||
sinceDays: 0,
|
||
});
|
||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||
assert.equal(layer.getParams().sinceDays, 365);
|
||
layer.setParams({ sinceDays: 0 });
|
||
assert.deepEqual(layer.getParams(), {
|
||
mapillary: true,
|
||
pano: 'flat',
|
||
sinceDays: 0,
|
||
});
|
||
layer.setParams({ mapillary: false });
|
||
assert.equal(layer.getParams().mapillary, false);
|
||
});
|
||
|
||
test('setParams ignores unknown keys and malformed values', async (t) => {
|
||
const { layer } = await startLayer(t);
|
||
layer.setParams({ pano: 'pano', sinceDays: 730 });
|
||
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
|
||
layer.setParams({ sinceDays: -3 });
|
||
layer.setParams(null);
|
||
assert.deepEqual(layer.getParams(), {
|
||
mapillary: true,
|
||
pano: 'pano',
|
||
sinceDays: 730,
|
||
});
|
||
});
|
||
|
||
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
|
||
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
||
view: true,
|
||
enable: false,
|
||
});
|
||
layer.attachViewerHost({});
|
||
layer.setParams({ mapillary: false });
|
||
layer.setParams({ mapillary: true });
|
||
await settle();
|
||
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||
// Enabling the layer is what draws it.
|
||
layer.enable(viewer);
|
||
await settle();
|
||
assert.ok(source.calls.tiles.length > 0);
|
||
assert.equal(viewer.credits.length, 1);
|
||
});
|
||
|
||
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||
const { layer, viewer } = await startLayer(t, { providers: [] });
|
||
assert.deepEqual(layer.providerIds, []);
|
||
assert.deepEqual(layer.getUIState().providers, []);
|
||
assert.equal(layer.getUIState().keyRequired, false);
|
||
assert.match(
|
||
layer.getUIState().coverage.hint,
|
||
/No street-level imagery providers/,
|
||
);
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
assert.deepEqual(layer.getUIState().providers, []);
|
||
});
|
||
|
||
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
|
||
|
||
/**
|
||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||
* click handler, camera events, a primitive list and a credit display.
|
||
*/
|
||
function fakeViewer() {
|
||
const credits = [];
|
||
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
|
||
let flight = null;
|
||
const flights = { started: 0, cancelled: 0 };
|
||
const stopFlight = () => {
|
||
const current = flight;
|
||
flight = null;
|
||
current?.cancel?.();
|
||
return Boolean(current);
|
||
};
|
||
const canvas = Object.assign(new EventTarget(), {
|
||
style: {},
|
||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||
disableRootEvents: true,
|
||
onwheel: null,
|
||
});
|
||
return {
|
||
credits,
|
||
flights,
|
||
scene: {
|
||
canvas,
|
||
primitives: { add: (p) => p, remove() {} },
|
||
// Enough of a scene for the position marker to clamp to the ground.
|
||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||
updateHeight: () => () => {},
|
||
getHeight: () => undefined,
|
||
},
|
||
camera: {
|
||
changed: new Cesium.Event(),
|
||
moveStart: new Cesium.Event(),
|
||
moveEnd: new Cesium.Event(),
|
||
flyToBoundingSphere(sphere, options = {}) {
|
||
stopFlight();
|
||
flight = options;
|
||
flights.started++;
|
||
},
|
||
cancelFlight() {
|
||
if (stopFlight()) flights.cancelled++;
|
||
},
|
||
},
|
||
creditDisplay: {
|
||
addStaticCredit: (credit) => credits.push(credit),
|
||
removeStaticCredit: (credit) => {
|
||
// Cesium ignores a credit it does not hold.
|
||
const at = credits.indexOf(credit);
|
||
if (at !== -1) credits.splice(at, 1);
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
|
||
async function enabledLayer(t, providers = [fakeProvider()]) {
|
||
const saved = globalThis.document;
|
||
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||
globalThis.document = Object.assign(new EventTarget(), {
|
||
createElement: () => ({ getContext: () => drawing }),
|
||
});
|
||
const viewer = fakeViewer();
|
||
const layer = createStreetLevelLayer({ providers });
|
||
layer.init(viewer);
|
||
layer.enable(viewer);
|
||
t.after(() => {
|
||
layer.destroy();
|
||
globalThis.document = saved;
|
||
});
|
||
await settle();
|
||
return { layer, viewer };
|
||
}
|
||
|
||
function posingProvider(options) {
|
||
const provider = fakeProvider(options);
|
||
const create = provider.create;
|
||
provider.create = (context) => {
|
||
const instance = create(context);
|
||
let emit = null;
|
||
instance.viewer.onPose = (listener) => {
|
||
emit = listener;
|
||
return () => {
|
||
emit = null;
|
||
};
|
||
};
|
||
instance.viewer.open = async (imageId) => {
|
||
provider.calls.open.push(imageId);
|
||
emit?.({
|
||
providerId: 'mapillary',
|
||
imageId,
|
||
position: { lon: -121.49, lat: 38.58 },
|
||
bearing: 90,
|
||
});
|
||
};
|
||
return instance;
|
||
};
|
||
return provider;
|
||
}
|
||
|
||
/** nearestImage lookups that answer when the test says, with their signals. */
|
||
function slowLookups() {
|
||
const lookups = [];
|
||
const nearestImage = (point, { signal } = {}) => {
|
||
const answer = deferred();
|
||
lookups.push({ point, signal, answer });
|
||
return answer.promise;
|
||
};
|
||
return { lookups, nearestImage };
|
||
}
|
||
|
||
/** A map stack controller that can be switched between stacks. */
|
||
function fakeMapStack(initial) {
|
||
let active = initial;
|
||
const listeners = new Set();
|
||
return {
|
||
getActiveId: () => active,
|
||
subscribe(listener) {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
},
|
||
switchTo(id) {
|
||
active = id;
|
||
for (const listener of listeners) listener();
|
||
},
|
||
listenerCount: () => listeners.size,
|
||
};
|
||
}
|
||
test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||
const first = fakeMapStack('photoreal');
|
||
layer.attachMapStackController(first);
|
||
assert.equal(first.listenerCount(), 1);
|
||
const second = fakeMapStack('photoreal');
|
||
layer.attachMapStackController(second);
|
||
assert.equal(first.listenerCount(), 0);
|
||
assert.equal(second.listenerCount(), 1);
|
||
layer.destroy();
|
||
assert.equal(second.listenerCount(), 0);
|
||
});
|
||
|
||
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
||
const { street } = layer.getUIState();
|
||
assert.equal(street.loading, false);
|
||
assert.match(street.error, /Open the Street Level panel/);
|
||
assert.equal(provider.calls.mount, 0);
|
||
});
|
||
|
||
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
|
||
const answer = deferred();
|
||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
layer.disable();
|
||
answer.resolve('img1');
|
||
assert.equal(await opening, false);
|
||
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
|
||
const { street } = layer.getUIState();
|
||
assert.equal(street.open, false);
|
||
assert.equal(street.loading, false);
|
||
assert.equal(street.error, null);
|
||
});
|
||
|
||
test('openNearest skips a provider switched off during its lookup', async (t) => {
|
||
const answer = deferred();
|
||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
layer.setProviderEnabled('mapillary', false);
|
||
answer.resolve('img1');
|
||
assert.equal(await opening, false);
|
||
assert.deepEqual(provider.calls.open, []);
|
||
assert.equal(layer.getUIState().street.loading, false);
|
||
});
|
||
|
||
test('a provider withdraws only the error it reported', async (t) => {
|
||
const provider = fakeProvider();
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
const { actions } = provider.context();
|
||
actions.reportError('Sequence images unavailable');
|
||
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
|
||
actions.reportError(null);
|
||
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
|
||
|
||
// The viewer's own error is not the provider's to clear.
|
||
layer.attachViewerHost(null);
|
||
actions.reportError('Sequence images unavailable');
|
||
await layer.openImage('mapillary', 'img1');
|
||
actions.reportError(null);
|
||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||
});
|
||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||
const { lookups, nearestImage } = slowLookups();
|
||
const provider = fakeProvider({ nearestImage });
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
|
||
lookups[1].answer.resolve('newer');
|
||
assert.equal(await newer, true);
|
||
lookups[0].answer.resolve('older');
|
||
assert.equal(await older, false);
|
||
assert.deepEqual(provider.calls.open, ['newer']);
|
||
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
|
||
assert.equal(lookups[1].signal?.aborted, false);
|
||
});
|
||
|
||
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
||
const { lookups, nearestImage } = slowLookups();
|
||
const provider = fakeProvider({ nearestImage });
|
||
const { layer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
// A cone click goes through the provider's openImage action.
|
||
assert.equal(await provider.context().actions.openImage('picked'), true);
|
||
lookups[0].answer.resolve('nearest');
|
||
assert.equal(await lookup, false);
|
||
assert.deepEqual(provider.calls.open, ['picked']);
|
||
assert.equal(lookups[0].signal?.aborted, true);
|
||
});
|
||
|
||
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
||
const { lookups, nearestImage } = slowLookups();
|
||
const provider = fakeProvider({ nearestImage });
|
||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
|
||
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
layer.closeViewer();
|
||
// The aborted fetch rejects, as fetch does; that is not the user's error.
|
||
lookups[0].answer.reject(new DOMException('aborted', 'AbortError'));
|
||
assert.equal(await closed, false);
|
||
let { street } = layer.getUIState();
|
||
assert.deepEqual(
|
||
[street.open, street.loading, street.error],
|
||
[false, false, null],
|
||
);
|
||
assert.equal(lookups[0].signal?.aborted, true, 'closing aborts the request');
|
||
|
||
const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||
layer.disable();
|
||
layer.enable(viewer);
|
||
lookups[1].answer.resolve('img1');
|
||
assert.equal(await disabled, false, 'nor does it open once back on');
|
||
assert.deepEqual(provider.calls.open, []);
|
||
({ street } = layer.getUIState());
|
||
assert.deepEqual(
|
||
[street.open, street.loading, street.error],
|
||
[false, false, null],
|
||
);
|
||
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||
});
|
||
|
||
function initialisedLayer(t, providers) {
|
||
const saved = globalThis.document;
|
||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||
globalThis.document = Object.assign(new EventTarget(), {
|
||
createElement: () => ({ getContext: () => drawing }),
|
||
});
|
||
const viewer = fakeViewer();
|
||
const layer = createStreetLevelLayer({ providers });
|
||
layer.init(viewer);
|
||
t.after(() => {
|
||
layer.destroy();
|
||
globalThis.document = saved;
|
||
});
|
||
return { layer, viewer };
|
||
}
|
||
|
||
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
|
||
const provider = fakeProvider();
|
||
const prewarmed = [];
|
||
const create = provider.create;
|
||
provider.create = (context) => {
|
||
const instance = create(context);
|
||
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
||
return instance;
|
||
};
|
||
const { layer, viewer } = initialisedLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
layer.setProviderEnabled('mapillary', false);
|
||
layer.setProviderEnabled('mapillary', true);
|
||
layer.setParams({ mapillary: false });
|
||
layer.setParams({ mapillary: true });
|
||
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
||
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||
// Enabling the layer is what activates it, once.
|
||
layer.enable(viewer);
|
||
assert.equal(provider.calls.activate, 1);
|
||
assert.equal(viewer.credits.length, 1);
|
||
});
|
||
|
||
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
|
||
async function withStreetView(
|
||
t,
|
||
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
|
||
) {
|
||
const { createGoogleProvider } = await import('./providers/google/index.js');
|
||
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
|
||
await import('../../testSupport/googleStreetViewFakes.mjs');
|
||
const fake = fakeStreetViewLibrary({
|
||
panoramas: {
|
||
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
|
||
},
|
||
nearest: nearest ?? (() => 'pano-a'),
|
||
});
|
||
const mapillaryLookups = [];
|
||
const mapillary = fakeProvider({
|
||
nearestImage: async (point) => {
|
||
mapillaryLookups.push(point);
|
||
return null;
|
||
},
|
||
});
|
||
mapillary.requiresKeyId = 'mapillary';
|
||
if (mapillaryKeyless) {
|
||
mapillary.stats.keyRequired = true;
|
||
const create = mapillary.create;
|
||
mapillary.create = (context) => ({
|
||
...create(context),
|
||
status: async () => ({ configured: false }),
|
||
});
|
||
}
|
||
const loader = fakeMapsLoader(fake.library);
|
||
const google = createGoogleProvider({
|
||
getApiKey: () => 'browser-key',
|
||
loader,
|
||
});
|
||
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
|
||
const stack = fakeMapStack(mapStack);
|
||
layer.attachMapStackController(stack);
|
||
layer.attachViewerHost(fakeHost());
|
||
return { layer, viewer, stack, fake, loader, mapillaryLookups };
|
||
}
|
||
|
||
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
|
||
const { layer } = await withStreetView(t);
|
||
const google = () =>
|
||
layer.getUIState().providers.find((p) => p.id === 'google');
|
||
assert.equal(google().on, false);
|
||
assert.equal(layer.requiresKeyId, 'mapillary');
|
||
layer.setProviderEnabled('google', true);
|
||
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
|
||
});
|
||
|
||
test('the layer starts with one source lit: the first that defaults on', async (t) => {
|
||
const { layer } = await enabledLayer(t, [
|
||
fakeProvider(),
|
||
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
|
||
]);
|
||
assert.deepEqual(
|
||
layer
|
||
.getUIState()
|
||
.providers.filter((p) => p.on)
|
||
.map((p) => p.id),
|
||
['mapillary'],
|
||
);
|
||
});
|
||
|
||
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
|
||
const { layer, stack, fake } = await withStreetView(t);
|
||
layer.setProviderEnabled('google', true);
|
||
let answer;
|
||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||
new Promise((resolve) => {
|
||
answer = resolve;
|
||
});
|
||
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
|
||
await settle();
|
||
assert.equal(layer.getUIState().street.loading, true);
|
||
stack.switchTo('bing');
|
||
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
|
||
assert.equal(await opening, false);
|
||
const { street } = layer.getUIState();
|
||
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
|
||
assert.equal(street.error, null);
|
||
assert.equal(street.open, false);
|
||
});
|
||
|
||
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
|
||
let answer;
|
||
const mapillary = fakeProvider({
|
||
nearestImage: () =>
|
||
new Promise((resolve) => {
|
||
answer = resolve;
|
||
}),
|
||
});
|
||
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
|
||
const { layer } = await enabledLayer(t, [mapillary, other]);
|
||
const opening = layer.openNearest({ lat: 1, lon: 2 });
|
||
layer.setProviderEnabled('panoramax', true);
|
||
answer('img-1');
|
||
assert.equal(await opening, false);
|
||
assert.equal(layer.getUIState().street.error, null);
|
||
assert.equal(layer.getUIState().street.loading, false);
|
||
});
|
||
|
||
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
|
||
const { layer, stack } = await withStreetView(t);
|
||
layer.setProviderEnabled('google', true);
|
||
stack.switchTo('esri-imagery');
|
||
assert.match(
|
||
layer.getUIState().coverage.hint,
|
||
/Google Street View needs the Google 3D map/,
|
||
);
|
||
layer.setProviderEnabled('mapillary', true);
|
||
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
|
||
});
|
||
|
||
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
|
||
const { layer } = await withStreetView(t);
|
||
const lit = () =>
|
||
layer
|
||
.getUIState()
|
||
.providers.filter((p) => p.on)
|
||
.map((p) => p.id);
|
||
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
|
||
layer.setProviderEnabled('google', true);
|
||
assert.deepEqual(lit(), ['google']);
|
||
assert.equal(layer.getParams().mapillary, false);
|
||
layer.setProviderEnabled('mapillary', true);
|
||
assert.deepEqual(lit(), ['mapillary']);
|
||
assert.equal(layer.getParams().google, false);
|
||
// Params that light one switch to it; reapplying them changes nothing.
|
||
layer.setParams({ google: true });
|
||
assert.deepEqual(lit(), ['google']);
|
||
layer.setParams({ google: true });
|
||
assert.deepEqual(lit(), ['google']);
|
||
// Params lighting both keep the one already on, however often applied.
|
||
for (let i = 0; i < 2; i++) {
|
||
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
|
||
assert.deepEqual(lit(), ['google']);
|
||
}
|
||
layer.setParams({ mapillary: true, google: false });
|
||
assert.deepEqual(lit(), ['mapillary']);
|
||
// Filter-only params leave the source alone.
|
||
layer.setParams({ pano: 'pano' });
|
||
assert.deepEqual(lit(), ['mapillary']);
|
||
// Darkening the only lit one leaves none: the panel turns the layer off.
|
||
layer.setProviderEnabled('mapillary', false);
|
||
assert.deepEqual(lit(), []);
|
||
});
|
||
|
||
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
|
||
const { layer, viewer, stack } = await withStreetView(t, {
|
||
mapStack: 'esri-imagery',
|
||
});
|
||
layer.setProviderEnabled('google', true);
|
||
await settle();
|
||
const ui = () => layer.getUIState();
|
||
const listed = () => ui().providers.map((p) => p.id);
|
||
const credited = () =>
|
||
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
||
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
|
||
assert.equal(credited(), false, 'no Google credit on another map');
|
||
assert.equal(layer.source, 'Mapillary');
|
||
assert.equal(layer.getStats().source, 'Mapillary');
|
||
assert.equal(await layer.openImage('google', 'pano-a'), false);
|
||
assert.match(ui().street.error, /needs the Google 3D map/);
|
||
assert.equal(layer.getParams().google, true, 'the choice is kept');
|
||
|
||
stack.switchTo('photoreal');
|
||
assert.deepEqual(listed(), ['mapillary', 'google']);
|
||
assert.equal(credited(), true);
|
||
assert.equal(layer.source, 'Mapillary · Google Street View');
|
||
assert.equal(await layer.openImage('google', 'pano-a'), true);
|
||
assert.equal(ui().street.providerId, 'google');
|
||
|
||
stack.switchTo('bing');
|
||
await settle();
|
||
assert.equal(ui().street.open, false, 'closed with the map');
|
||
assert.equal(credited(), false);
|
||
assert.deepEqual(listed(), ['mapillary']);
|
||
});
|
||
|
||
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
|
||
const saved = globalThis.document;
|
||
globalThis.document = new EventTarget();
|
||
t.after(() => {
|
||
globalThis.document = saved;
|
||
});
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const viewer = fakeViewer();
|
||
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
|
||
let ground;
|
||
viewer.camera.positionCartographic = { height: 4000 };
|
||
viewer.scene.globe = { show: false };
|
||
viewer.scene.sampleHeightSupported = true;
|
||
viewer.scene.sampleHeight = () => ground;
|
||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||
layer.init(viewer);
|
||
layer.enable(viewer);
|
||
t.after(() => layer.destroy());
|
||
assert.equal(layer.getUIState().groundClickReady, false);
|
||
|
||
ground = 2000;
|
||
t.mock.timers.tick(1000);
|
||
assert.equal(layer.getUIState().groundClickReady, true);
|
||
});
|
||
|
||
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
|
||
const saved = globalThis.document;
|
||
globalThis.document = new EventTarget();
|
||
t.after(() => {
|
||
globalThis.document = saved;
|
||
});
|
||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||
const viewer = fakeViewer();
|
||
let samples = 0;
|
||
viewer.camera.positionCartographic = { height: 4000 };
|
||
viewer.scene.globe = { show: false };
|
||
viewer.scene.sampleHeightSupported = true;
|
||
viewer.scene.sampleHeight = () => {
|
||
samples++;
|
||
return undefined;
|
||
};
|
||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||
layer.init(viewer);
|
||
layer.enable(viewer);
|
||
t.after(() => layer.destroy());
|
||
t.mock.timers.tick(60_000);
|
||
const measured = samples;
|
||
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
|
||
|
||
viewer.camera.moveEnd.raiseEvent();
|
||
layer.disable();
|
||
const atDisable = samples;
|
||
t.mock.timers.tick(60_000);
|
||
assert.equal(samples, atDisable, 'no measuring once the layer is off');
|
||
});
|
||
|
||
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
||
const { layer } = await withStreetView(t);
|
||
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
||
});
|
||
|
||
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
|
||
const { layer, mapillaryLookups } = await withStreetView(t, {
|
||
nearest: () => null,
|
||
});
|
||
layer.setProviderEnabled('google', true);
|
||
await settle();
|
||
const opened = await layer.openNearest(
|
||
{ lat: 38.58, lon: -121.49 },
|
||
{ providerIds: ['google'], frame: false },
|
||
);
|
||
assert.equal(opened, false);
|
||
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
|
||
assert.equal(
|
||
layer.getUIState().street.error,
|
||
'No street-level imagery within 50 m of that point',
|
||
);
|
||
});
|
||
|
||
test('the nearest-image refusal is written in the language of the page', async (t) => {
|
||
const { layer } = await withStreetView(t, { nearest: () => null });
|
||
layer.setProviderEnabled('google', true);
|
||
await settle();
|
||
setLocale('es');
|
||
try {
|
||
await layer.openNearest(
|
||
{ lat: 38.58, lon: -121.49 },
|
||
{ providerIds: ['google'], frame: false },
|
||
);
|
||
const error = layer.getUIState().street.error;
|
||
assert.notEqual(error, 'No street-level imagery within 50 m of that point');
|
||
assert.match(error, /50/, 'keeps the radius');
|
||
} finally {
|
||
setLocale('en');
|
||
}
|
||
});
|
||
|
||
test('an image opened from a ground click leaves the camera where it is', async (t) => {
|
||
const { layer, viewer } = await withStreetView(t);
|
||
layer.setProviderEnabled('google', true);
|
||
await settle();
|
||
await layer.openNearest(
|
||
{ lat: 38.58, lon: -121.49 },
|
||
{ providerIds: ['google'], frame: false },
|
||
);
|
||
assert.equal(layer.getUIState().street.imageId, 'pano-a');
|
||
assert.equal(viewer.flights.started, 0, 'no framing flight');
|
||
});
|
||
|
||
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
|
||
const { layer, loader } = await withStreetView(t, {
|
||
mapStack: 'esri-imagery',
|
||
});
|
||
layer.setProviderEnabled('google', true);
|
||
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
|
||
});
|
||
|
||
test('the button’s “centre of the view” is the ground under the middle of the screen', 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();
|
||
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;
|
||
});
|
||
await layer.openNearest(undefined, { aim: 'screen-centre' });
|
||
assert.equal(lookups.length, 1);
|
||
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
|
||
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
|
||
});
|
||
|
||
test('the button aimed at the sky opens nothing, and says to aim at a street', 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 ground pick misses: the middle of the screen is sky.
|
||
Object.assign(viewer.scene, {
|
||
pickPositionSupported: true,
|
||
pickPosition: () => undefined,
|
||
});
|
||
viewer.camera.pickEllipsoid = () => 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;
|
||
});
|
||
assert.equal(
|
||
await layer.openNearest(undefined, { aim: 'screen-centre' }),
|
||
false,
|
||
);
|
||
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
|
||
const { street } = layer.getUIState();
|
||
assert.match(street.error, /Aim the middle of the view at a street/);
|
||
assert.equal(street.loading, false);
|
||
});
|
||
|
||
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
|
||
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
|
||
await settle();
|
||
const stats = layer.getStats();
|
||
assert.equal(stats.keyRequired, true);
|
||
assert.deepEqual(stats.keyAlternatives, [
|
||
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
|
||
]);
|
||
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
|
||
stack.switchTo('esri-imagery');
|
||
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
|
||
// Lit, Street View draws: the layer is no longer key-gated.
|
||
stack.switchTo('photoreal');
|
||
layer.setProviderEnabled('google', true);
|
||
assert.equal(layer.getStats().keyRequired, false);
|
||
});
|
||
|
||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||
const failing = fakeProvider({
|
||
nearestImage: async () => {
|
||
throw new Error('KartaView did not answer in time — try again');
|
||
},
|
||
});
|
||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||
const { layer, viewer } = await enabledLayer(t, [failing, other]);
|
||
layer.attachViewerHost({});
|
||
await layer.openNearest({ lat: 1, lon: 1 });
|
||
assert.match(layer.getUIState().street.error, /did not answer/);
|
||
layer.setProviderEnabled('other', true);
|
||
await settle();
|
||
assert.equal(layer.getUIState().street.error, null);
|
||
// A provider's own open (KartaView's ‹ ›) can leave the camera alone.
|
||
await other.context().actions.openImage('img-1', { frame: false });
|
||
assert.deepEqual(other.calls.open, ['img-1']);
|
||
assert.equal(viewer.flights.started, 0);
|
||
});
|
||
|
||
test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => {
|
||
const provider = posingProvider();
|
||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||
layer.attachViewerHost({});
|
||
await provider.context().actions.openImage('img-1', { frame: false });
|
||
assert.deepEqual(provider.calls.open, ['img-1']);
|
||
assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera');
|
||
await provider.context().actions.openImage('img-2');
|
||
assert.equal(viewer.flights.started, 1, 'a default open frames the image');
|
||
});
|
||
|
||
test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => {
|
||
const lit = posingProvider({ nearestImage: async () => 'lit-near' });
|
||
const other = fakeProvider({
|
||
id: 'other',
|
||
pickPrefix: 'oth:',
|
||
nearestImage: async () => 'other-near',
|
||
});
|
||
const { layer, viewer } = await enabledLayer(t, [lit, other]);
|
||
layer.attachViewerHost({});
|
||
const point = { lat: 38.58, lon: -121.49 };
|
||
// The other source is off: its click asks nobody else.
|
||
await other.context().actions.openNearest(point);
|
||
await settle();
|
||
assert.deepEqual(lit.calls.open, [], 'the lit source was not asked');
|
||
assert.deepEqual(other.calls.open, []);
|
||
await lit.context().actions.openNearest(point);
|
||
assert.deepEqual(lit.calls.open, ['lit-near']);
|
||
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
||
});
|
||
|
||
test('KartaView through the core: its lines counted as streets', async (t) => {
|
||
const kartaView = createKartaViewProvider({
|
||
source: {
|
||
getTile: async () => ({ lines: [] }),
|
||
nearestPhotos: async () => [],
|
||
getPhoto: async () => {
|
||
throw new Error('KartaView has no such photo');
|
||
},
|
||
getSequencePhotos: async () => ({ photos: [], hasMore: false }),
|
||
probeImage: async () => true,
|
||
},
|
||
});
|
||
const { layer } = await enabledLayer(t, [kartaView]);
|
||
layer.setProviderEnabled('kartaview', true);
|
||
await settle();
|
||
const ui = layer.getUIState();
|
||
assert.equal(ui.coverage.label?.noun, 'streets');
|
||
});
|
||
|
||
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
||
const slow = slowLookups();
|
||
const looking = fakeProvider({ nearestImage: slow.nearestImage });
|
||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||
const { layer } = await enabledLayer(t, [looking, other]);
|
||
layer.attachViewerHost({});
|
||
const opening = layer.openNearest({ lat: 1, lon: 1 });
|
||
await settle();
|
||
assert.equal(layer.getUIState().street.loading, true);
|
||
layer.setParams({ mapillary: false, other: true });
|
||
assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped');
|
||
assert.equal(
|
||
layer.getUIState().street.loading,
|
||
false,
|
||
'no spinner left behind',
|
||
);
|
||
// The stopped lookup answers late: nothing opens, nothing is reported.
|
||
slow.lookups[0].answer.resolve('img1');
|
||
assert.equal(await opening, false);
|
||
assert.deepEqual(looking.calls.open, []);
|
||
assert.equal(layer.getUIState().street.error, null);
|
||
});
|
||
|
||
test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => {
|
||
const failing = fakeProvider({
|
||
nearestImage: async () => {
|
||
throw new Error('KartaView did not answer in time — try again');
|
||
},
|
||
});
|
||
const { layer: second } = await enabledLayer(t, [
|
||
failing,
|
||
fakeProvider({ id: 'other', pickPrefix: 'oth:' }),
|
||
]);
|
||
second.attachViewerHost({});
|
||
await second.openNearest({ lat: 1, lon: 1 });
|
||
assert.match(second.getUIState().street.error, /did not answer/);
|
||
second.setParams({ mapillary: false, other: true });
|
||
assert.equal(second.getUIState().street.error, null);
|
||
// Reapplying the same switches (a restore) leaves state alone.
|
||
await second.openNearest({ lat: 1, lon: 1 });
|
||
const kept = second.getUIState().street.error;
|
||
assert.match(kept, /No street-level imagery/);
|
||
second.setParams({ mapillary: false, other: true });
|
||
assert.equal(second.getUIState().street.error, kept);
|
||
});
|
||
|
||
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
|
||
const { presentStreetLevelPanel } =
|
||
await import('../../ui/streetLevelPresentation.js');
|
||
const { layer, stack } = await withStreetView(t);
|
||
layer.setProviderEnabled('google', true);
|
||
stack.switchTo('esri-imagery');
|
||
const view = presentStreetLevelPanel(layer.getUIState());
|
||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||
});
|