Files
gods-eye-view/src/layers/streetLevel/viewerHost.test.mjs
T
German KhokhlovandClaude Opus 5.5 28e0ba7104 fix(street-level): /revmain cleanup after the slim rebuild
- 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]>
2026-10-20 14:02:09 +04:00

568 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
import { createViewerHost } from './viewerHost.js';
// MapillaryJS needs a browser; the adapter tests swap in a stand-in library.
// Every import gets a fresh copy that waits for the current test's gate, so
// each test controls when its "download" finishes.
let libraryImports = 0;
registerHooks({
resolve(specifier, context, next) {
if (!specifier.startsWith('mapillary-js')) return next(specifier, context);
const kind = specifier === 'mapillary-js' ? 'library' : 'asset';
return {
url: `gev-test-stub:mapillary-js/${kind}?${++libraryImports}`,
shortCircuit: true,
};
},
load(url, context, next) {
if (!url.startsWith('gev-test-stub:mapillary-js/'))
return next(url, context);
const source = url.includes('/library?')
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
'export const Viewer = lib.Viewer;\n' +
'export const RenderMode = lib.RenderMode;\n'
: 'export default {};\n';
return { format: 'module', shortCircuit: true, source };
},
});
const { createMapillaryViewer } =
await import('./providers/mapillary/viewer.js');
/**
* A viewer host over `adapter`. `marker` records where the position marker
* was put (null once cleared), `framing` counts framing calls and
* `selected` every sequence the layer was asked to select.
*/
function harness(adapter) {
const selected = [];
const state = {
enabled: true,
notify() {},
street: { host: {}, renderMode: 'letterbox' },
sequence: { selectedId: null },
providers: new Map([
[
'mapillary',
{
def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' },
instance: {
viewer: adapter,
selectSequence(id) {
selected.push(id);
state.sequence.selectedId = id;
},
sequenceStats: () => ({ selectedId: state.sequence.selectedId }),
},
},
],
]),
};
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const parts = {
marker: {
set: (position, bearing) =>
marker.push(position && { position, bearing }),
clear: () => marker.push(null),
},
framing: {
begin: () => ({ generation: ++framing.begun }),
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
};
return {
state,
framing,
marker,
selected,
/** Esc on the map: the selected sequence is dropped. */
clearSequence() {
state.sequence.selectedId = null;
},
host: createViewerHost({ state, parts }),
};
}
/**
* A stand-in adapter. Like the real one, concurrent mounts share one
* construction (`gate` holds it open) and `unmount` destroys the viewer that
* every later `open` needs.
*/
function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
let emit = null;
let mounted = false;
return {
calls,
async mount() {
calls.mount++;
const attempt = calls.mount;
await gate?.promise;
if (attempt <= failMounts) throw new Error('library failed to load');
mounted = true;
},
/** A pose event from the library, as MapillaryJS fires them. */
emitPose(id, overrides = {}) {
emit?.({
imageId: id,
sequenceId: `seq-${id}`,
position: { lon: 1, lat: 2 },
bearing: 90,
isPano: false,
externalUrl: `https://example.test/${id}`,
...overrides,
});
},
/** Like MapillaryJS: the image event comes first, then `moveTo` settles. */
async open(id) {
if (!mounted) throw new Error('viewer is not mounted');
calls.open.push(id);
this.emitPose(id);
await openGate?.promise;
},
close() {},
unmount() {
calls.unmount++;
mounted = false;
},
resize() {},
onPose(listener) {
calls.listeners++;
emit = listener;
return () => {
calls.listeners--;
emit = null;
};
},
};
}
test('a failed mount is retried on the next open instead of being cached', async () => {
const adapter = fakeAdapter({ failMounts: 1 });
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
assert.equal(state.street.error, 'library failed to load');
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
await host.open('mapillary', 'b');
assert.equal(state.street.error, null);
assert.equal(adapter.calls.mount, 2);
assert.deepEqual(adapter.calls.open, ['b']);
assert.equal(state.street.imageId, 'b');
assert.equal(state.street.externalUrl, 'https://example.test/b');
});
test('concurrent opens share one mount and one pose listener', async () => {
const adapter = fakeAdapter();
const { host } = harness(adapter);
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
assert.equal(adapter.calls.mount, 1);
assert.equal(adapter.calls.listeners, 1);
});
test('unmounting while a mount is in flight does not leave it active', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
const opening = host.open('mapillary', 'a');
host.unmount();
await opening;
assert.equal(adapter.calls.listeners, 0);
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
assert.equal(state.street.open, false);
// Not left active: the next open mounts again.
await host.open('mapillary', 'b');
assert.equal(adapter.calls.mount, 2);
assert.equal(state.street.imageId, 'b');
});
test('a pose that arrives after the photo was closed does not bring it back', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
host.close();
const before = { ...state.street };
// The library's `image` event for a photo that was still loading.
adapter.emitPose('a');
assert.equal(state.street.open, false);
assert.equal(state.street.imageId, null);
assert.equal(state.street.sequenceId, null);
assert.deepEqual(state.street, before);
});
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { host, marker, framing } = harness(adapter);
await host.open('mapillary', 'a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
assert.deepEqual(marker.at(-1), {
position: { lon: 3, lat: 4 },
bearing: 180,
});
// Looking around in place is a pose too.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
assert.equal(marker.at(-1).bearing, 270);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
test('a pose on another sequence selects it, but not while the image still loads', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, selected, clearSequence } = harness(adapter);
const opening = host.open('mapillary', 'a');
await settle();
assert.equal(state.street.loading, true, 'the image is downloading');
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
assert.deepEqual(selected, [], 'no sequence lookup competes with it');
openGate.resolve();
assert.equal(await opening, true);
assert.deepEqual(selected, ['seq-a']);
// Stepping onto the next sequence (an arrow at a junction) selects it …
adapter.emitPose('b');
assert.deepEqual(selected, ['seq-a', 'seq-b']);
// … and walking along it does not ask again.
adapter.emitPose('c', { sequenceId: 'seq-b' });
assert.deepEqual(selected, ['seq-a', 'seq-b']);
// Once Esc cleared it, looking around on the same sequence leaves it so.
clearSequence();
adapter.emitPose('c', { sequenceId: 'seq-b', bearing: 10 });
assert.deepEqual(selected, ['seq-a', 'seq-b']);
});
test('closing the photo clears the marker', async () => {
const adapter = fakeAdapter();
const { host, marker } = harness(adapter);
await host.open('mapillary', 'a');
assert.notEqual(marker.at(-1), null);
host.close();
assert.equal(marker.at(-1), null);
});
test('an open still mounting when the photo is closed does nothing when the mount lands', async () => {
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
await settle();
host.close();
gate.resolve();
assert.equal(await opening, false);
assert.deepEqual(adapter.calls.open, [], 'the image is never opened');
assert.equal(framing.started, 0, 'the globe does not fly to it');
assert.deepEqual(selected, []);
assert.deepEqual(
[state.street.open, state.street.loading, state.street.imageId],
[false, false, null],
);
// The mounted viewer serves the next open.
assert.equal(await host.open('mapillary', 'b'), true);
assert.equal(state.street.imageId, 'b');
});
test('an image still opening when the photo is closed is neither framed nor selected', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a');
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
host.close();
openGate.resolve();
assert.equal(await opening, false);
assert.equal(framing.started, 0, 'the globe does not fly to it');
assert.deepEqual(selected, []);
assert.deepEqual(
[state.street.open, state.street.loading, state.street.imageId],
[false, false, null],
);
});
/**
* MapillaryJS, counting live viewers (each holds a WebGL context). A test can
* hold `moveTo(id)` on `viewers.moveGates.get(id)` and every position read on
* `viewers.positionGate`, and fire a viewer's events through `handlers`.
*/
function fakeLibrary() {
const gate = deferred();
const viewers = {
created: 0,
live: 0,
instances: [],
moveGates: new Map(),
moves: [],
positionGate: null,
};
class Viewer {
constructor(options) {
this.options = options;
viewers.created++;
viewers.live++;
viewers.instances.push(this);
this.removed = false;
this.playback = { stops: 0 };
this.handlers = {};
}
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
getComponent(name) {
if (name !== 'sequence') return undefined;
return { stop: () => this.playback.stops++ };
}
on(type, handler) {
this.handlers[type] = handler;
}
async getPosition() {
await viewers.positionGate?.promise;
return { lng: 1, lat: 2 };
}
async getPointOfView() {
return { bearing: 10, tilt: 0 };
}
async moveTo(id) {
viewers.moves.push(id);
await viewers.moveGates.get(id)?.promise;
return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
}
remove() {
if (this.removed) return;
this.removed = true;
viewers.live--;
}
setRenderMode() {}
resize() {}
}
globalThis.__gevMapillaryLibrary = async () => {
await gate.promise;
return { Viewer, RenderMode: {} };
};
return { gate, viewers };
}
test('the Mapillary viewer survives the layer going off and on during its first download', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'img1');
await Promise.resolve();
host.unmount();
const second = host.open('mapillary', 'img2');
await Promise.resolve();
gate.resolve();
await Promise.all([first, second]);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'img2');
await host.open('mapillary', 'img3');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'img3');
assert.equal(viewers.live, 1);
});
test('a Mapillary viewer unmounted mid-download is never built; the next mount builds afresh', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const stale = adapter.mount(element);
adapter.unmount();
const fresh = adapter.mount(element);
gate.resolve();
await assert.rejects(stale, /unmounted/);
await fresh;
assert.equal(viewers.created, 1, 'only the fresh mount built one');
await adapter.open('x');
adapter.unmount();
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('two mounts on one host during the first download build one Mapillary viewer', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const first = adapter.mount(element);
const second = adapter.mount(element);
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1);
adapter.unmount();
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('closing the photo stops sequence playback in the hidden viewer', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { host } = harness(adapter);
await host.open('mapillary', 'img1');
const [viewer] = viewers.instances;
assert.equal(viewer.playback.stops, 0);
host.close();
assert.equal(viewer.playback.stops, 1, 'playback stopped with the photo');
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
});
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
async function mountedMapillary() {
const library = fakeLibrary();
library.gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const poses = [];
adapter.onPose((pose) => poses.push(pose));
await adapter.mount({});
return { ...library, adapter, poses };
}
test('a Mapillary pose still being read when the photo closes is never emitted (M57)', async () => {
const { viewers, adapter, poses } = await mountedMapillary();
await adapter.open('a');
assert.deepEqual(
poses.map((pose) => pose.imageId),
['a'],
);
// The library steps to the next image; its position is still being read.
const [viewer] = viewers.instances;
viewers.positionGate = deferred();
viewer.handlers.image({
image: { id: 'b', cameraType: 'perspective', sequenceId: 'seq-b' },
});
adapter.close();
viewers.positionGate.resolve();
await settle();
assert.deepEqual(
poses.map((pose) => pose.imageId),
['a'],
'the closed photo does not come back',
);
});
test('a Mapillary image overtaken by a newer open emits no pose when it lands (M58)', async () => {
const { viewers, adapter, poses } = await mountedMapillary();
const gateA = deferred();
const gateB = deferred();
viewers.moveGates.set('a', gateA);
viewers.moveGates.set('b', gateB);
const openA = adapter.open('a');
await settle(); // A's image is downloading …
const openB = adapter.open('b'); // … when the user picks another one
gateA.resolve();
await openA;
assert.deepEqual(poses, [], 'nothing for the image the user left');
gateB.resolve();
await openB;
assert.deepEqual(
poses.map((pose) => [pose.imageId, pose.sequenceId]),
[['b', 'seq-b']],
);
});
test('back-to-back Mapillary opens download only the newer image', async () => {
const { viewers, adapter, poses } = await mountedMapillary();
await Promise.all([adapter.open('a'), adapter.open('b')]);
assert.deepEqual(viewers.moves, ['b']);
assert.deepEqual(
poses.map((pose) => pose.imageId),
['b'],
);
});
test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { host, framing } = harness(adapter);
const opening = host.open('mapillary', 'a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
assert.equal(framing.started, 0);
openGate.resolve();
assert.equal(await opening, true);
assert.equal(framing.started, 1);
});
/** The harness with a second provider, `google`, whose adapter has no render modes. */
function twoProviders() {
const mapillary = fakeAdapter();
mapillary.prewarm = async () => {};
mapillary.setRenderMode = () => {};
const google = fakeAdapter();
google.prewarm = async () => {};
const h = harness(mapillary);
h.state.providers.set('google', {
def: { id: 'google', name: 'Google Street View', label: 'STREET VIEW' },
instance: { viewer: google },
on: true,
});
h.state.providers.get('mapillary').on = true;
return { ...h, mapillary, google };
}
test('the render modes follow the viewer that is open', async () => {
const { state, host } = twoProviders();
await host.open('mapillary', 'm-1');
assert.equal(state.street.renderModes, true);
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false, 'Google always fills');
});
test('a pose can date its image by month and name its place', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', {
providerId: 'google',
capturedAtPrecision: 'month',
title: '1014 10th St',
capturedAt: Date.UTC(2023, 0, 1),
});
assert.equal(state.street.capturedAtPrecision, 'month');
assert.equal(state.street.title, '1014 10th St');
host.close();
assert.deepEqual(
[state.street.capturedAtPrecision, state.street.title],
['day', null],
);
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false);
host.close();
await host.open('google', 'g-2');
assert.equal(state.street.renderModes, false);
});
test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => {
const adapter = fakeAdapter();
let frameable = false;
adapter.setRenderMode = () => {};
adapter.supportsRenderModes = () => frameable;
const { state, host } = harness(adapter);
await host.open('mapillary', 'pano-1');
assert.equal(state.street.imageId, 'pano-1', 'the pose arrived');
assert.equal(state.street.renderModes, false);
frameable = true;
await host.open('mapillary', 'flat-1');
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
});
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', { providerId: 'google', license: 'CC BY-SA 4.0' });
assert.equal(state.street.license, 'CC BY-SA 4.0');
google.emitPose('g-1', { providerId: 'google' });
assert.equal(state.street.license, null, 'none given: none shown');
google.emitPose('g-1', { providerId: 'google', license: 'CC0' });
host.close();
assert.equal(state.street.license, null);
});