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]>
568 lines
19 KiB
JavaScript
568 lines
19 KiB
JavaScript
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);
|
||
});
|