mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(street-level): IC8 follow-ups for Google Street View
- An open after a failed one is no longer failed by the old panorama's status: only status_changed can reject; position_changed only resolves. - A nearest lookup aborted, timed out or refused while Google's script loads no longer sends its getPanorama request once the script arrives. - A panorama-details lookup that never answers times out after 10 s, cools down and retries; an unmount releases lookups in flight, and their late answers are dropped. - Every Maps loader on a page shares one script load and ready callback, and either can retry a failed script. - OPEN STREET VIEW aimed at the sky opens nothing and says to aim at a street, instead of looking up the camera footprint. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
76db4114ff
commit
10f1986e38
10 files changed
+279
-30
No files matched your search
@@ -22,6 +22,7 @@ import { createMeshSampler } from './meshSampler.js';
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
import {
|
||||
AIM_AT_GROUND_MESSAGE,
|
||||
FOLLOW_MAP_STACK_ID,
|
||||
NEAREST_RADIUS_M,
|
||||
POSITION_PICK_ID,
|
||||
@@ -298,11 +299,15 @@ export function createStreetLevelLayer({
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
/** The ground under the middle of the screen, or null (no scenePick, sky). */
|
||||
/**
|
||||
* The ground under the middle of the screen; null when the middle is sky;
|
||||
* undefined when nothing can aim (no scenePick, or a canvas with no size).
|
||||
*/
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const pick = state.services.scenePick?.pickGroundPosition;
|
||||
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) return null;
|
||||
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
|
||||
return undefined;
|
||||
const position = pick(state.viewer, {
|
||||
x: canvas.clientWidth / 2,
|
||||
y: canvas.clientHeight / 2,
|
||||
@@ -608,16 +613,25 @@ export function createStreetLevelLayer({
|
||||
/**
|
||||
* Open the nearest image any active provider (or only `providerIds`)
|
||||
* has around a point; without one, around the view centre, or with
|
||||
* `aim: 'screen-centre'` the ground under the middle of the screen.
|
||||
* `aim: 'screen-centre'` the ground under the middle of the screen. An
|
||||
* aim at the sky opens nothing rather than somewhere else.
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||
) {
|
||||
const view =
|
||||
point ||
|
||||
(aim === 'screen-centre' && screenCentreGround()) ||
|
||||
viewCentre(state.viewer);
|
||||
let view = point;
|
||||
if (!view && aim === 'screen-centre') {
|
||||
view = screenCentreGround();
|
||||
if (view === null) {
|
||||
abortNearest();
|
||||
state.street.loading = false;
|
||||
state.street.error = AIM_AT_GROUND_MESSAGE;
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
view ||= viewCentre(state.viewer);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
abortNearest();
|
||||
|
||||
@@ -649,6 +649,47 @@ test('the button’s “centre of the view” is the ground under the middle of
|
||||
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();
|
||||
|
||||
@@ -42,3 +42,7 @@ export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
* (Google Street View) only from this close: from higher up it is a misclick.
|
||||
*/
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
|
||||
/** OPEN STREET VIEW aimed at the sky: it opens what the user aims at, or nothing. */
|
||||
export const AIM_AT_GROUND_MESSAGE =
|
||||
'Aim the middle of the view at a street to look for imagery there';
|
||||
@@ -155,9 +155,13 @@ export function createGoogleProvider({
|
||||
signal?.throwIfAborted();
|
||||
const filter = context.getFilter();
|
||||
if (filter.pano === 'flat') return null;
|
||||
// Set once the caller has its answer (aborted, timed out, refused):
|
||||
// a lookup still waiting on Google's script then asks nothing.
|
||||
let settled = false;
|
||||
const lookup = (async () => {
|
||||
const lib = await loader.importLibrary('streetView');
|
||||
await getService();
|
||||
if (settled) return null;
|
||||
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
|
||||
try {
|
||||
const { data } = await service.getPanorama({
|
||||
@@ -177,6 +181,8 @@ export function createGoogleProvider({
|
||||
ms: NEAREST_TIMEOUT_MS,
|
||||
signal,
|
||||
loader,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const panoId = data?.location?.pano;
|
||||
if (!panoId) return null;
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
|
||||
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
|
||||
const READY_CALLBACK = '__gevGoogleMapsReady';
|
||||
/**
|
||||
* The page's one script load, kept on the page: every loader on it (two
|
||||
* application instances, say) waits on the same script and callback.
|
||||
*/
|
||||
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
|
||||
|
||||
/**
|
||||
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
|
||||
@@ -16,8 +21,6 @@ export function createMapsLoader({
|
||||
document = globalThis.document,
|
||||
global = globalThis,
|
||||
}) {
|
||||
/** The script load in flight or done; cleared when it fails, so a retry can. */
|
||||
let loading = null;
|
||||
let authFailed = false;
|
||||
let authHandlerInstalled = false;
|
||||
const authListeners = new Set();
|
||||
@@ -41,14 +44,15 @@ export function createMapsLoader({
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (loading) return loading;
|
||||
const key = getApiKey?.();
|
||||
if (!key)
|
||||
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
|
||||
if (global.google?.maps?.importLibrary)
|
||||
return (loading = Promise.resolve(global.google.maps));
|
||||
installAuthHandler();
|
||||
loading = new Promise((resolve, reject) => {
|
||||
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
|
||||
if (global.google?.maps?.importLibrary)
|
||||
return Promise.resolve(global.google.maps);
|
||||
// Cleared when the script fails, so a retry (from any loader) can.
|
||||
const loading = new Promise((resolve, reject) => {
|
||||
global[READY_CALLBACK] = () => {
|
||||
delete global[READY_CALLBACK];
|
||||
resolve(global.google.maps);
|
||||
@@ -62,13 +66,14 @@ export function createMapsLoader({
|
||||
callback: READY_CALLBACK,
|
||||
})}`;
|
||||
script.onerror = () => {
|
||||
loading = null;
|
||||
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
|
||||
script.remove();
|
||||
delete global[READY_CALLBACK];
|
||||
reject(new Error('The Google Maps JavaScript API could not load'));
|
||||
};
|
||||
document.head.append(script);
|
||||
});
|
||||
global[PAGE_LOAD] = loading;
|
||||
return loading;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,37 @@ test('the API loads once, with the key, asynchronously, and hands out libraries'
|
||||
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
|
||||
});
|
||||
|
||||
test('two loaders on one page share one script and both get the API', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const one = first.importLibrary('streetView');
|
||||
const two = second.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1, 'one script for the page');
|
||||
page.loadScript();
|
||||
assert.equal((await one).name, 'streetView');
|
||||
assert.equal((await two).name, 'streetView');
|
||||
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
|
||||
});
|
||||
|
||||
test('after a failed script, either loader on the page can retry it', async () => {
|
||||
const page = fakePage();
|
||||
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
|
||||
const failed = first.importLibrary('streetView');
|
||||
await settle();
|
||||
page.scripts[0].onerror();
|
||||
await assert.rejects(failed, /could not load/);
|
||||
const retry = second.importLibrary('streetView');
|
||||
const again = first.importLibrary('streetView');
|
||||
await settle();
|
||||
assert.equal(page.scripts.length, 1);
|
||||
page.loadScript();
|
||||
assert.equal((await retry).name, 'streetView');
|
||||
assert.equal((await again).name, 'streetView');
|
||||
});
|
||||
|
||||
test('without a key nothing loads', async () => {
|
||||
const page = fakePage();
|
||||
const loader = createMapsLoader({ getApiKey: () => null, ...page });
|
||||
|
||||
@@ -31,6 +31,8 @@ export const KEY_REJECTED_MESSAGE =
|
||||
export const NEAREST_TIMEOUT_MS = 10_000;
|
||||
/** A panorama that has not shown by then has failed to open. */
|
||||
export const OPEN_TIMEOUT_MS = 20_000;
|
||||
/** A panorama-details lookup that takes longer has failed (and can retry). */
|
||||
export const DETAILS_TIMEOUT_MS = 10_000;
|
||||
/** Minimum pause after a failed panorama-details lookup before a view can retry. */
|
||||
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
|
||||
|
||||
|
||||
@@ -206,6 +206,46 @@ test('aborting a lookup in flight gives up at once', async () => {
|
||||
await assert.rejects(lookup, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
|
||||
const fake = fakeStreetViewLibrary({
|
||||
panoramas: PANORAMAS,
|
||||
nearest: () => 'fresh',
|
||||
});
|
||||
const loader = fakeMapsLoader(fake.library, { deferred: true });
|
||||
const instance = createGoogleProvider({
|
||||
getApiKey: () => 'browser-key',
|
||||
loader,
|
||||
}).create({
|
||||
services: {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
actions: { reportError() {} },
|
||||
});
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
// Aborted, then timed out, while the script still loads.
|
||||
const controller = new AbortController();
|
||||
const aborted = instance.nearestImage(
|
||||
{ lat: 0, lon: 0 },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
await assert.rejects(aborted, { name: 'AbortError' });
|
||||
const expired = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
|
||||
await assert.rejects(expired, /did not answer/);
|
||||
// The script arrives: a current lookup still gets its answer.
|
||||
const current = instance.nearestImage({ lat: 0, lon: 0 });
|
||||
loader.release();
|
||||
await flush();
|
||||
assert.equal(await current, 'fresh');
|
||||
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
|
||||
});
|
||||
|
||||
test('a Google outage is an error, not "no imagery here"', async () => {
|
||||
const { instance, fake } = setup();
|
||||
fake.library.StreetViewService.prototype.getPanorama = () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DETAILS_RETRY_COOLDOWN_MS,
|
||||
DETAILS_TIMEOUT_MS,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
NO_ANSWER_MESSAGE,
|
||||
@@ -49,8 +50,11 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
let subscriptions = [];
|
||||
/** Panorama id → its {capturedAt, creator, title}. */
|
||||
const described = new Map();
|
||||
/** Panorama ids whose lookup is in flight. */
|
||||
const lookingUp = new Set();
|
||||
/**
|
||||
* Panorama id → its lookup in flight ({timer}). A late answer to a lookup
|
||||
* no longer here (timed out, or unmounted) is dropped.
|
||||
*/
|
||||
const lookingUp = new Map();
|
||||
/** Panorama id → earliest retry time after a failed details lookup. */
|
||||
const retryAfter = new Map();
|
||||
const listeners = new Set();
|
||||
@@ -84,29 +88,49 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
|
||||
/**
|
||||
* Look up a panorama's date, photographer and address once; the pose
|
||||
* already out is sent again with them. After a failure, view events can
|
||||
* retry once the cooldown expires.
|
||||
* already out is sent again with them. A lookup that fails or takes longer
|
||||
* than DETAILS_TIMEOUT_MS lets view events retry once the cooldown expires.
|
||||
*/
|
||||
async function describe(panoId) {
|
||||
if (described.has(panoId) || lookingUp.has(panoId)) return;
|
||||
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
|
||||
lookingUp.add(panoId);
|
||||
const lookup = { timer: null };
|
||||
lookingUp.set(panoId, lookup);
|
||||
const current = () => lookingUp.get(panoId) === lookup;
|
||||
try {
|
||||
service ||= getService
|
||||
? await getService()
|
||||
: new (await ensureLibrary()).StreetViewService();
|
||||
const { data } = await service.getPanorama({ pano: panoId });
|
||||
const { data } = await new Promise((resolve, reject) => {
|
||||
lookup.timer = setTimeout(
|
||||
() => reject(new Error(NO_ANSWER_MESSAGE)),
|
||||
DETAILS_TIMEOUT_MS,
|
||||
);
|
||||
(async () => {
|
||||
service ||= getService
|
||||
? await getService()
|
||||
: new (await ensureLibrary()).StreetViewService();
|
||||
return service.getPanorama({ pano: panoId });
|
||||
})().then(resolve, reject);
|
||||
});
|
||||
// Released by an unmount: the viewer it was for is gone.
|
||||
if (!current()) return;
|
||||
described.set(panoId, metaFrom(data));
|
||||
retryAfter.delete(panoId);
|
||||
} catch {
|
||||
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
|
||||
if (current())
|
||||
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
|
||||
return;
|
||||
} finally {
|
||||
lookingUp.delete(panoId);
|
||||
clearTimeout(lookup.timer);
|
||||
if (current()) lookingUp.delete(panoId);
|
||||
}
|
||||
publishPose();
|
||||
}
|
||||
|
||||
/** Drop every lookup in flight: a remount looks again. */
|
||||
function releaseLookups() {
|
||||
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
|
||||
lookingUp.clear();
|
||||
}
|
||||
|
||||
/** Emit the pose on screen now, dated once its lookup has answered. */
|
||||
function publishPose() {
|
||||
const panoId = panorama?.getPano?.();
|
||||
@@ -189,7 +213,9 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
|
||||
);
|
||||
cancelShown = () => finish(resolve);
|
||||
const check = () => {
|
||||
// Only a status event can fail the open: at a position event the
|
||||
// status may still be the previous panorama's failure.
|
||||
const onStatus = () => {
|
||||
if (pendingOpen !== panoId) return finish(resolve);
|
||||
const status = instance.getStatus();
|
||||
if (status === 'OK') finish(resolve);
|
||||
@@ -199,9 +225,14 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
new Error('Street View has no imagery for this place'),
|
||||
);
|
||||
};
|
||||
const subscriptions = ['status_changed', 'position_changed'].map((name) =>
|
||||
instance.addListener(name, check),
|
||||
);
|
||||
const onPosition = () => {
|
||||
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
|
||||
finish(resolve);
|
||||
};
|
||||
const subscriptions = [
|
||||
instance.addListener('status_changed', onStatus),
|
||||
instance.addListener('position_changed', onPosition),
|
||||
];
|
||||
function finish(settle, value) {
|
||||
cancelShown = null;
|
||||
clearTimeout(timer);
|
||||
@@ -247,6 +278,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
cancelShown?.();
|
||||
releaseLookups();
|
||||
generation++;
|
||||
destroyPanorama();
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
import { DETAILS_RETRY_COOLDOWN_MS } from './policy.js';
|
||||
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.js';
|
||||
import {
|
||||
fakeHost,
|
||||
fakeMapsLoader,
|
||||
@@ -48,6 +48,7 @@ test('opening a panorama shows it and emits a provider-neutral pose', async () =
|
||||
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(panorama.visible, true);
|
||||
await settle(); // the pose is dated once its lookup answers
|
||||
const pose = poses.at(-1);
|
||||
assert.deepEqual(
|
||||
{
|
||||
@@ -207,6 +208,7 @@ test('an open never waits on the date lookup', async () => {
|
||||
new Promise(() => {}); // the metadata never answers
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
viewer.unmount();
|
||||
});
|
||||
|
||||
test('closing while a panorama loads settles the open and drops its listeners', async () => {
|
||||
@@ -329,3 +331,75 @@ test('a failed panorama does not delay details for another panorama', async (t)
|
||||
'returning respects the pause',
|
||||
);
|
||||
});
|
||||
|
||||
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await assert.rejects(viewer.open('gone'), /no imagery/);
|
||||
// The new panorama moves first; its OK status follows.
|
||||
await viewer.open('pano-a');
|
||||
assert.equal(poses.at(-1).imageId, 'pano-a');
|
||||
});
|
||||
|
||||
test('a details lookup that never answers times out, cools down and retries', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let hang = true;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
|
||||
};
|
||||
const opening = viewer.open('pano-a');
|
||||
await flush();
|
||||
await opening;
|
||||
assert.deepEqual(requests, ['pano-a']);
|
||||
fake.built[0].setPov({ heading: 5, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
|
||||
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
|
||||
await flush();
|
||||
hang = false;
|
||||
fake.built[0].setPov({ heading: 6, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
|
||||
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
|
||||
fake.built[0].setPov({ heading: 7, pitch: 0 });
|
||||
await flush();
|
||||
assert.deepEqual(requests, ['pano-a', 'pano-a']);
|
||||
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
|
||||
assert.equal(poses.at(-1).bearing, 7);
|
||||
});
|
||||
|
||||
test('a remount looks up again a panorama whose lookup hung', async () => {
|
||||
const { fake, viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
|
||||
const requests = [];
|
||||
let answerHung;
|
||||
fake.library.StreetViewService.prototype.getPanorama = function (request) {
|
||||
requests.push(request.pano);
|
||||
if (requests.length === 1)
|
||||
return new Promise((resolve) => {
|
||||
answerHung = () => resolve(getPanorama.call(this, request));
|
||||
});
|
||||
return getPanorama.call(this, request);
|
||||
};
|
||||
await viewer.open('pano-b');
|
||||
viewer.unmount();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano-b');
|
||||
await settle();
|
||||
assert.deepEqual(requests, ['pano-b', 'pano-b']);
|
||||
assert.equal(poses.at(-1).creator, 'Ada');
|
||||
// The hung answer arriving now is for a viewer that is gone: dropped.
|
||||
const count = poses.length;
|
||||
answerHung();
|
||||
await settle();
|
||||
assert.equal(poses.length, count);
|
||||
});
|
||||
Reference in new issue
Block a user