mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
- Releasing a held viewer frees the old source's adapter without resetting the panel, so a replacement's error, outcome and armed OPEN survive; a cross-source replacement drops the old photo's details and marker. - The action holds the open photo only for its own switch-off: a source switched by hand during the load stops it and reports cancelled. - A posted view waiting on terrain watches the camera and stands down after a navigation or a globe press; a flat or straight-down camera asks no terrain. - An arming link opens the panel without persisting the layout, and a posted view's street-level step runs as programmatic. - An off-globe street_level point throws TypeError. - zh-Hant uses the file's wording for the new strings; README and CURRENT-STATE count 35 tools.
1294 lines
39 KiB
JavaScript
1294 lines
39 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import {
|
||
applyView,
|
||
installViews,
|
||
isEmbedded,
|
||
keepPanelRendering,
|
||
panelAwareVisibility,
|
||
} from './embed.js';
|
||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||
import { createView, viewToParams } from '../view/index.js';
|
||
|
||
const fakeViewer = () => {
|
||
const flights = [];
|
||
return {
|
||
flights,
|
||
camera: {
|
||
flyTo(options) {
|
||
flights.push(options);
|
||
options.complete();
|
||
},
|
||
},
|
||
};
|
||
};
|
||
const fakeLayers = (enabled) => ({
|
||
getAll: () =>
|
||
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
|
||
id,
|
||
enabled: enabled.has(id),
|
||
})),
|
||
isEnabled: (id) => enabled.has(id),
|
||
});
|
||
const recorder = (answers = {}) => {
|
||
const calls = [];
|
||
const options = [];
|
||
const run = async (name, args, runOptions) => {
|
||
calls.push([name, args]);
|
||
options.push([name, runOptions]);
|
||
const answer = answers[name];
|
||
return typeof answer === 'function'
|
||
? answer(calls)
|
||
: (answer ?? { ok: true });
|
||
};
|
||
return { calls, run, options };
|
||
};
|
||
|
||
test('embed mode is opt-in by query parameter', () => {
|
||
assert.equal(isEmbedded({ search: '?embed=1' }), true);
|
||
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
|
||
});
|
||
|
||
test('a view is applied through the app actions in order', async () => {
|
||
const viewer = fakeViewer();
|
||
const { calls, run } = recorder();
|
||
const view = createView({
|
||
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
|
||
layers: ['ais-live-vessels'],
|
||
style: 'thermal',
|
||
map: 'osm',
|
||
annotations: [{ type: 'pin', target: 'Taipei 101' }],
|
||
});
|
||
const steps = await applyView(view, {
|
||
viewer,
|
||
dataManager: fakeLayers(new Set(['earthquakes'])),
|
||
run,
|
||
});
|
||
assert.deepEqual(
|
||
calls.map(([name, args]) => [
|
||
name,
|
||
args.layerId ?? args.style ?? args.stack ?? null,
|
||
args.enabled ?? null,
|
||
]),
|
||
[
|
||
['set_visual_style', 'thermal', null],
|
||
['set_map_stack', 'osm', null],
|
||
['set_layer_visibility', 'earthquakes', false],
|
||
['set_layer_visibility', 'ais-live-vessels', true],
|
||
['stop_tracking', null, null],
|
||
['clear_annotations', null, null],
|
||
['annotate_map', null, null],
|
||
],
|
||
);
|
||
assert.equal(viewer.flights.length, 1);
|
||
assert.ok(steps.every((step) => step.ok));
|
||
assert.ok(steps.some((step) => step.step === 'camera'));
|
||
});
|
||
|
||
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
|
||
const { options, run } = recorder();
|
||
await applyView(
|
||
createView({
|
||
camera: { lat: 48.86, lon: 2.29 },
|
||
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
|
||
}),
|
||
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
|
||
);
|
||
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
|
||
assert.equal(runOptions?.origin, 'programmatic');
|
||
});
|
||
|
||
test('a followed entity is retried until its layer has it', async () => {
|
||
let tries = 0;
|
||
const { run } = recorder({
|
||
track_entity: () => ({ ok: ++tries >= 3 }),
|
||
});
|
||
const view = createView({
|
||
camera: { lat: 0, lon: 0 },
|
||
follow: { kind: 'aircraft', id: 'abc123' },
|
||
});
|
||
const steps = await applyView(view, {
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
retryMs: 1,
|
||
});
|
||
assert.equal(tries, 3);
|
||
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
|
||
});
|
||
|
||
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
|
||
const parent = { postMessage() {} };
|
||
const windowRef = new EventTarget();
|
||
windowRef.parent = parent;
|
||
windowRef.document = { body: { classList: new Set() } };
|
||
windowRef.document.body.classList.add = Set.prototype.add;
|
||
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
|
||
const { calls, run } = recorder();
|
||
const asked = [];
|
||
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
|
||
const terrain = {
|
||
getHeights: async (points) => {
|
||
asked.push(points);
|
||
return points.map(() => ({ ellipsoid: 1590 }));
|
||
},
|
||
};
|
||
const remove = installViews({
|
||
shell,
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
terrain,
|
||
location: { search: '?embed=1', hash: '' },
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
const event = new Event('message');
|
||
Object.assign(event, {
|
||
source: parent,
|
||
origin: 'https://host.example',
|
||
data: {
|
||
type: 'gev:view',
|
||
id: 1,
|
||
view: {
|
||
camera: {
|
||
lat: 39.74,
|
||
lon: -104.99,
|
||
altitude_m: 1909,
|
||
pitch_deg: -30,
|
||
heading_deg: 0,
|
||
},
|
||
street_level: { provider: 'mapillary' },
|
||
},
|
||
},
|
||
});
|
||
windowRef.dispatchEvent(event);
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
remove();
|
||
const [, args] = calls.find(([name]) => name === 'show_street_level');
|
||
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
|
||
// ellipsoid altitude would give.
|
||
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
|
||
assert.ok(
|
||
args.latitude > 39.744 && args.latitude < 39.746,
|
||
String(args.latitude),
|
||
);
|
||
assert.equal(args.longitude, -104.99);
|
||
});
|
||
|
||
test('an embedded page takes views only from its parent and answers it', async () => {
|
||
const posted = [];
|
||
const targets = [];
|
||
const parent = {
|
||
postMessage: (message, target) => {
|
||
posted.push(message);
|
||
targets.push(target);
|
||
},
|
||
};
|
||
const windowRef = new EventTarget();
|
||
windowRef.parent = parent;
|
||
windowRef.document = { body: { classList: new Set() } };
|
||
windowRef.document.body.classList.add = Set.prototype.add;
|
||
const shell = {
|
||
initialRestorePromise: Promise.resolve(),
|
||
clean: null,
|
||
setCleanView(value) {
|
||
this.clean = value;
|
||
},
|
||
};
|
||
const { calls, run } = recorder();
|
||
const remove = installViews({
|
||
shell,
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: { search: '?embed=1', hash: '' },
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(shell.clean, true);
|
||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||
const send = (source, data, origin = 'https://host.example') => {
|
||
const event = new Event('message');
|
||
Object.assign(event, { source, data, origin });
|
||
windowRef.dispatchEvent(event);
|
||
};
|
||
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
|
||
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
|
||
send(parent, {
|
||
type: 'gev:view',
|
||
id: 3,
|
||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||
});
|
||
// A view kept from before Snow was retired still applies, on Normal.
|
||
send(parent, {
|
||
type: 'gev:view',
|
||
id: 4,
|
||
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.deepEqual(posted[1], {
|
||
type: 'gev:view-applied',
|
||
id: 2,
|
||
ok: false,
|
||
error: 'A view needs a camera lat and lon',
|
||
});
|
||
assert.equal(posted[2].id, 3);
|
||
assert.equal(posted[2].ok, true);
|
||
assert.ok(
|
||
calls.some(
|
||
([name, args]) =>
|
||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||
),
|
||
);
|
||
assert.equal(posted[3].id, 4);
|
||
assert.equal(posted[3].ok, true);
|
||
assert.ok(
|
||
calls.some(
|
||
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
|
||
),
|
||
);
|
||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||
// Ready names no data and goes to any parent; answers go only to the
|
||
// origin that asked.
|
||
assert.deepEqual(targets, [
|
||
'*',
|
||
'https://host.example',
|
||
'https://host.example',
|
||
'https://host.example',
|
||
]);
|
||
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();
|
||
windowRef.parent = windowRef;
|
||
windowRef.postMessage = (message) => posted.push(message);
|
||
windowRef.document = { body: { classList: new Set() } };
|
||
windowRef.document.body.classList.add = Set.prototype.add;
|
||
Object.assign(windowRef, {
|
||
requestAnimationFrame: () => 1,
|
||
cancelAnimationFrame() {},
|
||
setInterval: () => 1,
|
||
clearInterval() {},
|
||
});
|
||
globalThis.GEV_EMBED_INLINE = true;
|
||
try {
|
||
assert.equal(isEmbedded({ search: '' }), true);
|
||
const remove = installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: recorder().run,
|
||
location: { search: '', hash: '' },
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
|
||
const event = new Event('message');
|
||
Object.assign(event, {
|
||
source: windowRef,
|
||
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
|
||
});
|
||
windowRef.dispatchEvent(event);
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
assert.equal(posted[1].id, 7);
|
||
assert.equal(posted[1].ok, true);
|
||
remove();
|
||
} finally {
|
||
delete globalThis.GEV_EMBED_INLINE;
|
||
}
|
||
});
|
||
|
||
test('polled layers keep refreshing in an inline panel its host reports hidden', async (t) => {
|
||
const listeners = new Set();
|
||
const doc = {
|
||
hidden: true,
|
||
visibilityState: 'hidden',
|
||
addEventListener: (_type, fn) => listeners.add(fn),
|
||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||
};
|
||
const settle = async () => {
|
||
for (let i = 0; i < 3; i++) await Promise.resolve();
|
||
};
|
||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||
let updates = 0;
|
||
const manager = new LayerLifecycle(
|
||
{},
|
||
{ visibilityTarget: panelAwareVisibility(doc) },
|
||
);
|
||
manager.register({
|
||
id: 'flights',
|
||
name: 'flights',
|
||
icon: '',
|
||
source: 'test',
|
||
updateInterval: 1000,
|
||
async init() {},
|
||
enable() {},
|
||
disable() {},
|
||
async update() {
|
||
updates++;
|
||
await Promise.resolve();
|
||
},
|
||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||
});
|
||
globalThis.GEV_EMBED_INLINE = true;
|
||
try {
|
||
await manager.setEnabled('flights', true);
|
||
await settle();
|
||
const enabled = updates;
|
||
t.mock.timers.tick(1000);
|
||
await settle();
|
||
t.mock.timers.tick(1000);
|
||
await settle();
|
||
assert.equal(updates, enabled + 2, 'the panel keeps polling');
|
||
|
||
delete globalThis.GEV_EMBED_INLINE;
|
||
t.mock.timers.tick(1000);
|
||
await settle();
|
||
assert.equal(updates, enabled + 2, 'outside a panel a hidden page pauses');
|
||
} finally {
|
||
delete globalThis.GEV_EMBED_INLINE;
|
||
await manager.destroyAll();
|
||
}
|
||
assert.equal(listeners.size, 0);
|
||
});
|
||
|
||
test('annotations in a link are drawn after it restores, embedded or not', async () => {
|
||
const { calls, run } = recorder();
|
||
const an = encodeURIComponent(
|
||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||
);
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [
|
||
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
|
||
]);
|
||
});
|
||
|
||
test('a panel keeps drawing from a timer while animation frames stop', () => {
|
||
let clock = 0;
|
||
let tick = null;
|
||
let frame = null;
|
||
const windowRef = {
|
||
requestAnimationFrame: (fn) => ((frame = fn), 1),
|
||
cancelAnimationFrame: () => (frame = null),
|
||
setInterval: (fn) => ((tick = fn), 1),
|
||
clearInterval: () => (tick = null),
|
||
};
|
||
const drawn = [];
|
||
const viewer = {
|
||
resize: () => drawn.push('resize'),
|
||
render: () => drawn.push('render'),
|
||
};
|
||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||
// Frames arrive: the render loop draws, so the timer does nothing.
|
||
clock = 100;
|
||
frame();
|
||
tick();
|
||
assert.deepEqual(drawn, []);
|
||
// Frames stop, as in a panel its host reports hidden: the timer draws.
|
||
clock = 400;
|
||
tick();
|
||
assert.deepEqual(drawn, ['resize', 'render']);
|
||
stop();
|
||
assert.equal(tick, null);
|
||
assert.equal(frame, null);
|
||
});
|
||
|
||
test('a panel reports a render error in full and stops drawing', () => {
|
||
let listener = null;
|
||
let tick = null;
|
||
const windowRef = {
|
||
requestAnimationFrame: () => 1,
|
||
cancelAnimationFrame() {},
|
||
setInterval: (fn) => ((tick = fn), 1),
|
||
clearInterval() {},
|
||
};
|
||
const drawn = [];
|
||
const viewer = {
|
||
resize() {},
|
||
render: () => drawn.push('render'),
|
||
scene: {
|
||
renderError: {
|
||
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
|
||
},
|
||
},
|
||
};
|
||
const logged = [];
|
||
const original = console.error;
|
||
console.error = (...args) => logged.push(args.join(' '));
|
||
try {
|
||
let clock = 0;
|
||
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
|
||
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
|
||
assert.match(logged[0], /render error: .*"message":"worker failed"/);
|
||
clock = 1000;
|
||
tick();
|
||
assert.deepEqual(drawn, []);
|
||
stop();
|
||
assert.equal(listener, null);
|
||
} finally {
|
||
console.error = original;
|
||
}
|
||
});
|
||
|
||
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
|
||
const viewer = fakeViewer();
|
||
const { calls, run } = recorder();
|
||
const steps = await applyView(
|
||
createView({
|
||
camera: { lat: 32.7, lon: -117.2 },
|
||
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
|
||
}),
|
||
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
|
||
);
|
||
// Following worked at once, so no camera flight ended it.
|
||
assert.equal(viewer.flights.length, 0);
|
||
assert.deepEqual(calls.slice(-2), [
|
||
['track_entity', { query: 'ae1234', layerId: 'military' }],
|
||
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
|
||
]);
|
||
assert.ok(steps.every((step) => step.ok));
|
||
assert.throws(
|
||
() =>
|
||
createView({
|
||
camera: { lat: 0, lon: 0 },
|
||
follow: { kind: 'satellite', id: '25544', cockpit: true },
|
||
}),
|
||
/no cockpit view/,
|
||
);
|
||
});
|
||
|
||
test('a view with street-level photos lets the action fly and open them', async () => {
|
||
const viewer = fakeViewer();
|
||
const { calls, run } = recorder();
|
||
const view = createView({
|
||
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
|
||
street_level: { provider: 'google' },
|
||
});
|
||
const steps = await applyView(view, {
|
||
viewer,
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
});
|
||
assert.deepEqual(
|
||
calls.map(([name]) => name),
|
||
[
|
||
'set_map_stack',
|
||
'stop_tracking',
|
||
'clear_annotations',
|
||
'show_street_level',
|
||
],
|
||
);
|
||
assert.deepEqual(calls.at(-1)[1], {
|
||
latitude: 38.5799,
|
||
longitude: -121.4937,
|
||
provider: 'google',
|
||
fly: true,
|
||
});
|
||
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
|
||
assert.ok(steps.every((step) => step.ok));
|
||
assert.equal(steps.at(-1).step, 'show_street_level');
|
||
});
|
||
|
||
test('a link to street-level photos opens them after it restores', async () => {
|
||
const { calls, run } = recorder();
|
||
const toasts = [];
|
||
const panels = [];
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_showToast: (text) => toasts.push(text),
|
||
setPanelCollapsed: (...args) => panels.push(args),
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [
|
||
[
|
||
'show_street_level',
|
||
{
|
||
latitude: 38.58,
|
||
longitude: -121.49,
|
||
provider: 'mapillary',
|
||
fly: true,
|
||
autoOpen: false,
|
||
},
|
||
],
|
||
]);
|
||
assert.deepEqual(
|
||
[toasts, panels],
|
||
[[], []],
|
||
'an opened photo needs no toast or panel nudge',
|
||
);
|
||
// A failure (no key) is not an armed state either: it says why instead.
|
||
const failed = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'key',
|
||
error: 'needs its key',
|
||
}),
|
||
});
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_showToast: (text) => toasts.push(text),
|
||
setPanelCollapsed: (...args) => panels.push(args),
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: failed.run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(
|
||
[toasts, panels],
|
||
[
|
||
[
|
||
"Street Level cannot open this link's street-level photo without a working key",
|
||
],
|
||
[],
|
||
],
|
||
'it says why, and nothing to press opens',
|
||
);
|
||
});
|
||
|
||
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
|
||
const { calls, run } = recorder();
|
||
const params = viewToParams(
|
||
createView({
|
||
camera: { lat: 1, lon: 2 },
|
||
follow: { kind: 'satellite', id: '25544' },
|
||
}),
|
||
);
|
||
params.set('sl', 'mapillary,1.00000,2.00000');
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: { search: '', hash: `#${params}` },
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, []);
|
||
});
|
||
|
||
test('a failing link step does not take the next one with it', async () => {
|
||
const calls = [];
|
||
const run = async (name, args) => {
|
||
calls.push(name);
|
||
if (name === 'annotate_map') throw new Error('marks failed');
|
||
return { ok: true, armed: false, ...args };
|
||
};
|
||
const an = encodeURIComponent(
|
||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||
);
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
|
||
});
|
||
|
||
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
|
||
for (const embed of [false, true]) {
|
||
const toasts = [];
|
||
const { calls, run } = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'key',
|
||
provider: 'google',
|
||
providerName: 'Google Street View',
|
||
error: 'Google Street View needs its key',
|
||
}),
|
||
});
|
||
const windowRef = new EventTarget();
|
||
if (embed) {
|
||
windowRef.document = { body: { classList: { add() {} } } };
|
||
windowRef.parent = { postMessage() {} };
|
||
}
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
setCleanView() {},
|
||
_showToast: (text) => toasts.push(text),
|
||
setPanelCollapsed() {
|
||
throw new Error('nothing to arm');
|
||
},
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: embed ? '?embed=1' : '',
|
||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||
},
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(calls[0][0], 'show_street_level');
|
||
assert.deepEqual(
|
||
toasts,
|
||
embed
|
||
? []
|
||
: [
|
||
"Google Street View cannot open this link's street-level photo without a working key",
|
||
],
|
||
);
|
||
}
|
||
});
|
||
|
||
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
|
||
const toasts = [];
|
||
const panels = [];
|
||
const revealed = [];
|
||
const { calls, run } = recorder({
|
||
show_street_level: () => ({ ok: true, armed: true }),
|
||
});
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_showToast: (text) => toasts.push(text),
|
||
setPanelCollapsed: (id, collapsed, options) =>
|
||
panels.push([id, collapsed, options]),
|
||
revealPanel: (id) => revealed.push(id),
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(calls[0][1].autoOpen, false);
|
||
assert.deepEqual(toasts, [
|
||
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
|
||
]);
|
||
assert.deepEqual(
|
||
panels,
|
||
[['street-level-panel', false, { explicit: true, persist: false }]],
|
||
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
|
||
);
|
||
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
|
||
// Adding ?embed=1 to a published link does not lift the gate: the page is
|
||
// still a plain top-level load.
|
||
const framedToasts = [];
|
||
const framedPanels = [];
|
||
const framed = recorder({
|
||
show_street_level: () => ({ ok: true, armed: true }),
|
||
});
|
||
const windowRef = new EventTarget();
|
||
windowRef.document = { body: { classList: { add() {} } } };
|
||
windowRef.parent = { postMessage() {} };
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
setCleanView() {},
|
||
_showToast: (text) => framedToasts.push(text),
|
||
setPanelCollapsed: (...args) => framedPanels.push(args),
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: framed.run,
|
||
location: {
|
||
search: '?embed=1',
|
||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||
},
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(framed.calls[0][1].autoOpen, false);
|
||
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
|
||
assert.deepEqual([framedToasts, framedPanels], [[], []]);
|
||
// Only the panel page's own link for a result issued just now is the
|
||
// client's request: a host that mounts the panel again hands back an old
|
||
// result, and that one only arms, like any link.
|
||
globalThis.GEV_EMBED_INLINE = true;
|
||
try {
|
||
for (const fresh of [true, false]) {
|
||
globalThis.GEV_EMBED_FRESH = fresh;
|
||
const inline = recorder({ show_street_level: () => ({ ok: true }) });
|
||
const inlineWindow = new EventTarget();
|
||
inlineWindow.document = { body: { classList: { add() {} } } };
|
||
inlineWindow.postMessage = () => {};
|
||
// The inline panel keeps rendering from a timer; give it inert clocks.
|
||
Object.assign(inlineWindow, {
|
||
requestAnimationFrame: () => 1,
|
||
cancelAnimationFrame() {},
|
||
setTimeout: () => 1,
|
||
clearTimeout() {},
|
||
setInterval: () => 1,
|
||
clearInterval() {},
|
||
});
|
||
const stop = installViews({
|
||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: inline.run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||
},
|
||
windowRef: inlineWindow,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
|
||
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
|
||
stop();
|
||
}
|
||
} finally {
|
||
delete globalThis.GEV_EMBED_INLINE;
|
||
delete globalThis.GEV_EMBED_FRESH;
|
||
}
|
||
// applyView (a posted view) opens too.
|
||
const posted = recorder();
|
||
await applyView(
|
||
createView({
|
||
camera: { lat: 1, lon: 2 },
|
||
street_level: { provider: 'google' },
|
||
}),
|
||
{
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: posted.run,
|
||
},
|
||
);
|
||
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
|
||
});
|
||
|
||
test('a view whose photos fail before any flight still gets its camera', async () => {
|
||
const viewer = fakeViewer();
|
||
const { calls, run } = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'key',
|
||
error: 'needs its key',
|
||
}),
|
||
});
|
||
const view = createView({
|
||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||
street_level: { provider: 'google' },
|
||
});
|
||
const steps = await applyView(view, {
|
||
viewer,
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
});
|
||
assert.equal(viewer.flights.length, 1, 'the view flies itself');
|
||
assert.deepEqual(
|
||
steps.map((step) => [step.step, step.ok]),
|
||
[
|
||
['set_map_stack', true],
|
||
['stop_tracking', true],
|
||
['clear_annotations', true],
|
||
['show_street_level', false],
|
||
['camera', true],
|
||
],
|
||
);
|
||
assert.ok(calls.some(([name]) => name === 'show_street_level'));
|
||
// A flight that happened (the action reports where) is not repeated.
|
||
const flown = fakeViewer();
|
||
const { run: flownRun } = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'imagery',
|
||
location: 'Somewhere',
|
||
error: 'no imagery',
|
||
}),
|
||
});
|
||
await applyView(view, {
|
||
viewer: flown,
|
||
dataManager: fakeLayers(new Set()),
|
||
run: flownRun,
|
||
});
|
||
assert.equal(flown.flights.length, 0);
|
||
});
|
||
|
||
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
|
||
const log = [];
|
||
let releaseStreetLevel = null;
|
||
let releaseMarks = null;
|
||
const run = async (name, args) => {
|
||
log.push(`start:${name}`);
|
||
if (name === 'annotate_map')
|
||
await new Promise((resolve) => {
|
||
releaseMarks = resolve;
|
||
});
|
||
if (name === 'show_street_level')
|
||
await new Promise((resolve) => {
|
||
releaseStreetLevel = resolve;
|
||
});
|
||
log.push(`end:${name}`);
|
||
return {
|
||
ok: true,
|
||
...(name === 'show_street_level' ? { location: 'x' } : {}),
|
||
};
|
||
};
|
||
const windowRef = new EventTarget();
|
||
windowRef.document = { body: { classList: { add() {} } } };
|
||
// A framing page: answers go to it, not to the app's own window.
|
||
windowRef.parent = {
|
||
postMessage: (message) => log.push(`post:${message.type}`),
|
||
};
|
||
const an = encodeURIComponent(
|
||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||
);
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '?embed=1',
|
||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||
},
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
// A view arrives while the link's photos are still opening.
|
||
windowRef.dispatchEvent(
|
||
Object.assign(new Event('message'), {
|
||
source: windowRef.parent,
|
||
origin: 'null',
|
||
data: {
|
||
type: 'gev:view',
|
||
id: 'v1',
|
||
view: { camera: { lat: 3, lon: 4 } },
|
||
},
|
||
}),
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
|
||
assert.deepEqual(
|
||
actions(),
|
||
['start:annotate_map'],
|
||
'the photos wait for the marks',
|
||
);
|
||
releaseMarks();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(actions().slice(0, 3), [
|
||
'start:annotate_map',
|
||
'end:annotate_map',
|
||
'start:show_street_level',
|
||
]);
|
||
assert.ok(
|
||
!log.includes('start:stop_tracking'),
|
||
'the posted view waits for the link',
|
||
);
|
||
releaseStreetLevel();
|
||
for (
|
||
let tick = 0;
|
||
tick < 20 && !log.includes('post:gev:view-applied');
|
||
tick++
|
||
)
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
|
||
true,
|
||
);
|
||
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
|
||
});
|
||
|
||
test('an aborted install never runs the link photos', async () => {
|
||
const { calls, run } = recorder();
|
||
const controller = new AbortController();
|
||
controller.abort();
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
signal: controller.signal,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, []);
|
||
});
|
||
|
||
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
|
||
const calls = [];
|
||
let releaseMarks = null;
|
||
const shell = {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_navigationGeneration: 7,
|
||
};
|
||
const run = async (name) => {
|
||
calls.push(name);
|
||
if (name === 'annotate_map')
|
||
await new Promise((resolve) => {
|
||
releaseMarks = resolve;
|
||
});
|
||
return { ok: true };
|
||
};
|
||
const an = encodeURIComponent(
|
||
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
|
||
);
|
||
installViews({
|
||
shell,
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, ['annotate_map']);
|
||
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
|
||
releaseMarks();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(
|
||
calls,
|
||
['annotate_map'],
|
||
'the link did not fly over the newer navigation',
|
||
);
|
||
|
||
// Unchanged navigation: the photos open as before.
|
||
const quiet = [];
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_navigationGeneration: 3,
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run: async (name) => {
|
||
quiet.push(name);
|
||
return { ok: true };
|
||
},
|
||
location: {
|
||
search: '',
|
||
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
|
||
});
|
||
|
||
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
|
||
const { calls, run, options } = recorder();
|
||
installViews({
|
||
shell: { initialRestorePromise: Promise.resolve() },
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(calls.length, 1);
|
||
assert.deepEqual(options, [
|
||
['show_street_level', { origin: 'programmatic' }],
|
||
]);
|
||
});
|
||
|
||
test('a start link whose restore a newer link superseded opens nothing', async () => {
|
||
const { calls, run } = recorder();
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
|
||
_navigationGeneration: 4,
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
|
||
});
|
||
|
||
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
|
||
for (const [camera, expected] of [
|
||
['cancelled', 0],
|
||
['skipped', 0],
|
||
['applied', 1],
|
||
]) {
|
||
const { calls, run } = recorder();
|
||
installViews({
|
||
shell: {
|
||
initialRestorePromise: Promise.resolve({
|
||
status: 'settled',
|
||
share: { camera },
|
||
}),
|
||
_navigationGeneration: 4,
|
||
},
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location: {
|
||
search: '',
|
||
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
|
||
},
|
||
windowRef: new EventTarget(),
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(calls.length, expected, camera);
|
||
}
|
||
});
|
||
|
||
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
|
||
const { calls, run, options } = recorder();
|
||
const windowRef = new EventTarget();
|
||
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
|
||
const shell = {
|
||
initialRestorePromise: Promise.resolve(),
|
||
_navigationGeneration: 1,
|
||
};
|
||
const remove = installViews({
|
||
shell,
|
||
viewer: fakeViewer(),
|
||
dataManager: fakeLayers(new Set()),
|
||
run,
|
||
location,
|
||
windowRef,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [], 'the start link has no photos');
|
||
const settled = (status, camera = 'applied') =>
|
||
windowRef.dispatchEvent(
|
||
Object.assign(new Event('gev:share-navigation-restore-settled'), {
|
||
detail: { status, share: { camera } },
|
||
}),
|
||
);
|
||
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
|
||
shell._navigationGeneration = 2; // the restore's own flight
|
||
settled('failed');
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [], 'a failed restore opens nothing');
|
||
// The recipient dragged or searched during the restore flight: the camera
|
||
// is theirs, and the link does not pull it back.
|
||
shell._navigationGeneration = 3;
|
||
for (const camera of ['cancelled', 'superseded']) {
|
||
settled('settled', camera);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(
|
||
calls,
|
||
[],
|
||
`an interrupted restore (${camera}) opens nothing`,
|
||
);
|
||
}
|
||
settled('settled');
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.deepEqual(calls, [
|
||
[
|
||
'show_street_level',
|
||
{
|
||
latitude: 48.8874,
|
||
longitude: 2.3495,
|
||
provider: 'mapillary',
|
||
fly: true,
|
||
autoOpen: false,
|
||
},
|
||
],
|
||
]);
|
||
assert.deepEqual(options.at(-1), [
|
||
'show_street_level',
|
||
{ origin: 'programmatic' },
|
||
]);
|
||
remove();
|
||
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
|
||
settled('settled');
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(calls.length, 1, 'removed with the install');
|
||
});
|
||
|
||
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
|
||
// Photo B, opened by a click while the posted view looked up its photo.
|
||
const street = { open: false, imageId: null };
|
||
const dataManager = {
|
||
...fakeLayers(new Set()),
|
||
layers: new Map([
|
||
[
|
||
'street-level',
|
||
{
|
||
module: {
|
||
closeViewer: () =>
|
||
Object.assign(street, { open: false, imageId: null }),
|
||
},
|
||
},
|
||
],
|
||
]),
|
||
};
|
||
const { run } = recorder({
|
||
show_street_level: () => {
|
||
Object.assign(street, { open: true, imageId: 'b' });
|
||
return {
|
||
ok: false,
|
||
stage: 'imagery',
|
||
cancelled: true,
|
||
superseded: true,
|
||
error: 'The camera moved on before the photo opened',
|
||
};
|
||
},
|
||
});
|
||
const view = createView({
|
||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||
street_level: { provider: 'mapillary' },
|
||
});
|
||
const viewer = fakeViewer();
|
||
const steps = await applyView(view, { viewer, dataManager, run });
|
||
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
|
||
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
|
||
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
|
||
});
|
||
|
||
test('a view whose photo was found but could not load keeps its error in view', async () => {
|
||
const closed = [];
|
||
const dataManager = {
|
||
...fakeLayers(new Set()),
|
||
layers: new Map([
|
||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||
]),
|
||
};
|
||
const { run } = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'imagery',
|
||
imageryError: true,
|
||
location: '1.00000, 2.00000',
|
||
error: 'Image could not be opened',
|
||
}),
|
||
});
|
||
const view = createView({
|
||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||
street_level: { provider: 'mapillary' },
|
||
});
|
||
await applyView(view, { viewer: fakeViewer(), dataManager, run });
|
||
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
|
||
});
|
||
|
||
test('a view whose photos fail closes the photo an earlier view opened', async () => {
|
||
const closed = [];
|
||
const dataManager = {
|
||
...fakeLayers(new Set()),
|
||
layers: new Map([
|
||
['street-level', { module: { closeViewer: () => closed.push(true) } }],
|
||
]),
|
||
};
|
||
const { run } = recorder({
|
||
show_street_level: () => ({
|
||
ok: false,
|
||
stage: 'key',
|
||
error: 'needs its key',
|
||
}),
|
||
});
|
||
const view = createView({
|
||
camera: { lat: 1, lon: 2, altitude_m: 500 },
|
||
street_level: { provider: 'google' },
|
||
});
|
||
const viewer = fakeViewer();
|
||
await applyView(view, { viewer, dataManager, run });
|
||
assert.equal(closed.length, 1, 'closed before the view flies away');
|
||
assert.equal(viewer.flights.length, 1);
|
||
const opened = recorder({ show_street_level: () => ({ ok: true }) });
|
||
await applyView(view, { viewer, dataManager, run: opened.run });
|
||
assert.equal(closed.length, 1, 'an opened photo stays');
|
||
});
|