mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
test(street-level): close the mutation survivors; Esc in rich text
A mutation review left 18 earlier survivors; each now has a test that fails with the mutant applied: - the expanded viewer returns into the panel on shrink, close and destroy (the fake DOM had no parentNode, so reinsertion was never exercised); the fake DOM gains offsetParent and a MutationObserver that honours its options, and a <body> child added while expanded goes inert; - each pose moves the marker, drives FOLLOW and selects a new sequence; close() retires an open still mounting or opening; a provider toggled while the layer is off activates nothing; the adapter drops late and superseded poses; - share-link defaults: "any date" (0 days), the codec max pinned to the filter's MAX_SINCE_DAYS (layerState cannot import it across package boundaries, so a test ties the two), Mapillary on when a link omits it; - Esc that something else handled, or typed in a field, keeps the selection; a key problem outranks LOADING on the pill; - the marker's terrain height (mesh, DEM, clamp then re-place); - proxy: LRU memory cache and its half-budget rule, Retry-After capped, a request after a cancelled fetch starts a fresh one; - date-line tile ranking with and without focus; the horizon cull idles while the camera stands still; the old zoom goes as soon as the new one lands; a rate limit holds every refresh inside its wait. One real bug found and fixed: Esc inside a contenteditable cleared the selected sequence (selection.js now ignores isContentEditable targets). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9dde7b4a62
commit
3af0f80cdb
15 files changed
+1198
-30
No files matched your search
@@ -419,6 +419,8 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
flat: 'f',
|
flat: 'f',
|
||||||
}),
|
}),
|
||||||
// "Captured since" as relative days, so a link means the same next year.
|
// "Captured since" as relative days, so a link means the same next year.
|
||||||
|
// The filter's MAX_SINCE_DAYS (streetLevel/policy.js); layerState.test
|
||||||
|
// pins the two together.
|
||||||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||||
]),
|
]),
|
||||||
radio: Object.freeze([
|
radio: Object.freeze([
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||||
import radioLayer from './radio.js';
|
import radioLayer from './radio.js';
|
||||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||||
|
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
|
||||||
|
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
|
||||||
|
|
||||||
function deferred() {
|
function deferred() {
|
||||||
let resolve;
|
let resolve;
|
||||||
@@ -2642,3 +2644,63 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
|
|||||||
decoded,
|
decoded,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Street Level options decoded from a v2 link with these `lo` assignments. */
|
||||||
|
function streetLevelOptions(lo) {
|
||||||
|
const params = new URLSearchParams([
|
||||||
|
['v', '2'],
|
||||||
|
['l', '0'],
|
||||||
|
]);
|
||||||
|
if (lo) params.set('lo', lo);
|
||||||
|
return decodeLayerStateParams(params).options['street-level'];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Street Level: every option round-trips through a share link and stored state', () => {
|
||||||
|
const state = createDefaultLayerState();
|
||||||
|
state.enabledLayerIds = ['street-level'];
|
||||||
|
state.options['street-level'] = {
|
||||||
|
mapillary: false,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
|
};
|
||||||
|
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||||
|
assert.deepEqual(decoded.options['street-level'], {
|
||||||
|
mapillary: false,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||||
|
'street-level'
|
||||||
|
],
|
||||||
|
state.options['street-level'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
|
||||||
|
// The codec's ceiling is the filter's MAX_SINCE_DAYS: a longer window is
|
||||||
|
// rejected (read as "any date"), the longest one is kept whole.
|
||||||
|
assert.equal(
|
||||||
|
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
|
||||||
|
MAX_SINCE_DAYS,
|
||||||
|
);
|
||||||
|
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
|
||||||
|
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
|
||||||
|
for (const { days } of SINCE_STOPS)
|
||||||
|
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
|
||||||
|
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||||
|
assert.deepEqual(streetLevelOptions(''), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'all',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||||
|
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||||
|
assert.equal(
|
||||||
|
createDefaultLayerState().options['street-level'].mapillary,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveFilter,
|
resolveFilter,
|
||||||
sameFilter,
|
sameFilter,
|
||||||
} from './filter.js';
|
} from './filter.js';
|
||||||
|
import { MAX_SINCE_DAYS } from './policy.js';
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
@@ -81,3 +82,20 @@ test('sameFilter compares the stored form', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeFilter takes "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||||
|
// Dragging SINCE back to ANY DATE, or a link that says so, must stick.
|
||||||
|
assert.equal(
|
||||||
|
normalizeFilter({ sinceDays: 0 }, { pano: 'all', sinceDays: 365 })
|
||||||
|
.sinceDays,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeFilter(
|
||||||
|
{ sinceDays: MAX_SINCE_DAYS },
|
||||||
|
{ pano: 'all', sinceDays: 0 },
|
||||||
|
).sinceDays,
|
||||||
|
MAX_SINCE_DAYS,
|
||||||
|
'the longest window is kept whole',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -419,3 +419,66 @@ test('the Mapillary provider hands the lookup signal to its source (review IC8 P
|
|||||||
);
|
);
|
||||||
assert.equal(requests[0]?.signal, signal);
|
assert.equal(requests[0]?.signal, signal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
|
||||||
|
const provider = fakeProvider();
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||||
|
assert.equal(layer.getParams().sinceDays, 365);
|
||||||
|
layer.setParams({ sinceDays: 0 });
|
||||||
|
assert.deepEqual(layer.getParams(), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
provider.filters.at(-1).sinceMs,
|
||||||
|
null,
|
||||||
|
'providers drop the cut-off',
|
||||||
|
);
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
assert.equal(layer.getParams().mapillary, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A layer initialised on a stand-in viewer but never enabled. */
|
||||||
|
function initialisedLayer(t, providers) {
|
||||||
|
const saved = globalThis.document;
|
||||||
|
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
createElement: () => ({ getContext: () => drawing }),
|
||||||
|
});
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
const layer = createStreetLevelLayer({ providers });
|
||||||
|
layer.init(viewer);
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
return { layer, viewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
|
||||||
|
const provider = fakeProvider();
|
||||||
|
const prewarmed = [];
|
||||||
|
const create = provider.create;
|
||||||
|
provider.create = (context) => {
|
||||||
|
const instance = create(context);
|
||||||
|
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
const { layer, viewer } = initialisedLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
layer.setProviderEnabled('mapillary', false);
|
||||||
|
layer.setProviderEnabled('mapillary', true);
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
layer.setParams({ mapillary: true });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
||||||
|
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
||||||
|
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||||
|
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
||||||
|
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||||
|
// Enabling the layer is what activates it, once.
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.equal(provider.calls.activate, 1);
|
||||||
|
assert.equal(viewer.credits.length, 1);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createMarker } from './marker.js';
|
import { createMarker } from './marker.js';
|
||||||
|
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
|
||||||
|
|
||||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -11,7 +12,7 @@ globalThis.document ??= {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||||
function setup() {
|
function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||||
const preRender = new Set();
|
const preRender = new Set();
|
||||||
const viewer = {
|
const viewer = {
|
||||||
@@ -33,7 +34,7 @@ function setup() {
|
|||||||
const state = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
viewer,
|
viewer,
|
||||||
surface: 'draped',
|
surface,
|
||||||
marker: {
|
marker: {
|
||||||
// Stands in for the billboard collection; ensure() keeps it.
|
// Stands in for the billboard collection; ensure() keeps it.
|
||||||
collection: {
|
collection: {
|
||||||
@@ -44,7 +45,7 @@ function setup() {
|
|||||||
billboard: null,
|
billboard: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const marker = createMarker({ state, parts: {} });
|
const marker = createMarker({ state, parts });
|
||||||
const frame = () => {
|
const frame = () => {
|
||||||
for (const listener of [...preRender]) listener();
|
for (const listener of [...preRender]) listener();
|
||||||
};
|
};
|
||||||
@@ -76,3 +77,113 @@ test('destroying the marker stops the horizon cull', () => {
|
|||||||
marker.destroy();
|
marker.destroy();
|
||||||
assert.equal(preRender.size, 0);
|
assert.equal(preRender.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare earth from a ground caster (null until its cell is fetched) and the
|
||||||
|
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
|
||||||
|
*/
|
||||||
|
function terrainParts({ dem = null, mesh } = {}) {
|
||||||
|
const ground = { dem, prepares: [] };
|
||||||
|
const sampler = { mesh, requests: [], listeners: [] };
|
||||||
|
return {
|
||||||
|
ground,
|
||||||
|
sampler,
|
||||||
|
parts: {
|
||||||
|
groundCaster: {
|
||||||
|
groundAt: () => ground.dem,
|
||||||
|
prepare(points) {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
ground.prepares.push({ points, resolve });
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meshSampler: {
|
||||||
|
meshAt: () => sampler.mesh,
|
||||||
|
request: (points) => sampler.requests.push(points),
|
||||||
|
onSampled: (listener) => sampler.listeners.push(listener),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightOf = (billboard) =>
|
||||||
|
Cesium.Cartographic.fromCartesian(billboard.position).height;
|
||||||
|
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||||
|
|
||||||
|
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
||||||
|
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
||||||
|
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||||
|
marker.set(SPOT, 45);
|
||||||
|
const billboard = state.marker.billboard;
|
||||||
|
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
|
||||||
|
`height ${heightOf(billboard)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
|
||||||
|
const { parts, sampler } = terrainParts({ dem: 10 });
|
||||||
|
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||||
|
marker.set(SPOT, 45);
|
||||||
|
const billboard = state.marker.billboard;
|
||||||
|
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
|
||||||
|
`height ${heightOf(billboard)}`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
|
||||||
|
// The sample lands: the marker moves onto the road surface.
|
||||||
|
sampler.mesh = 10.5;
|
||||||
|
for (const listener of sampler.listeners) listener();
|
||||||
|
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
|
||||||
|
const { parts, ground } = terrainParts({ mesh: 11 });
|
||||||
|
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||||
|
marker.set(SPOT, 45);
|
||||||
|
const billboard = state.marker.billboard;
|
||||||
|
assert.equal(
|
||||||
|
billboard.heightReference,
|
||||||
|
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
);
|
||||||
|
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
||||||
|
ground.dem = 10;
|
||||||
|
ground.prepares[0].resolve(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||||
|
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
||||||
|
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ground that arrives after the marker moved on does not move it back', async () => {
|
||||||
|
const { parts, ground } = terrainParts();
|
||||||
|
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||||
|
marker.set(SPOT, 0);
|
||||||
|
const elsewhere = { lon: -121.5, lat: 38.6 };
|
||||||
|
marker.set(elsewhere, 0);
|
||||||
|
ground.dem = 10;
|
||||||
|
ground.prepares[0].resolve(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
const placed = Cesium.Cartographic.fromCartesian(
|
||||||
|
state.marker.billboard.position,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draped mode clamps the marker to the globe whatever the ground says', () => {
|
||||||
|
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
|
||||||
|
const { state, marker } = setup({ parts, surface: 'draped' });
|
||||||
|
marker.set(SPOT, 0);
|
||||||
|
assert.equal(
|
||||||
|
state.marker.billboard.heightReference,
|
||||||
|
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||||
|
);
|
||||||
|
assert.equal(ground.prepares.length, 0);
|
||||||
|
});
|
||||||
@@ -38,3 +38,21 @@ test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
|||||||
assert.equal(empty.providers.size, 0);
|
assert.equal(empty.providers.size, 0);
|
||||||
assert.deepEqual(empty.filter, current);
|
assert.deepEqual(empty.filter, current);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ sinceDays: 0 },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
|
||||||
|
// The provider switch off is a value too, not an absence.
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...decodeParams(
|
||||||
|
{ mapillary: false },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
|
||||||
|
).providers,
|
||||||
|
],
|
||||||
|
[['mapillary', false]],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -839,3 +839,57 @@ test('only lines within the mesh sampler range of the camera are asked for (revi
|
|||||||
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
|
||||||
|
const { viewer, source, state, coverage, bytes } = setup();
|
||||||
|
coverage.refresh();
|
||||||
|
source.calls[0].resolve(bytes);
|
||||||
|
await settle();
|
||||||
|
const [old] = viewer.scene.groundPrimitives.items;
|
||||||
|
assert.ok(old, 'the z14 lines are drawn');
|
||||||
|
viewer.view.height = 3000; // zoom out to z13
|
||||||
|
coverage.refresh();
|
||||||
|
assert.equal(state.coverage.zoom, 13);
|
||||||
|
const fresh = source.calls.slice(1);
|
||||||
|
assert.ok(fresh.length > 0, 'the new zoom is loading');
|
||||||
|
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
|
||||||
|
assert.ok(viewer.scene.groundPrimitives.items.has(old));
|
||||||
|
for (const call of fresh) {
|
||||||
|
assert.ok(
|
||||||
|
viewer.scene.groundPrimitives.items.has(old),
|
||||||
|
'kept while a new tile is still loading',
|
||||||
|
);
|
||||||
|
call.resolve(new Uint8Array());
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
// The last new tile is in: the old lines go now, long before the wait.
|
||||||
|
assert.equal(state.coverage.pending.size, 0);
|
||||||
|
assert.equal(state.coverage.stale.size, 0);
|
||||||
|
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
||||||
|
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
||||||
|
coverage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||||
|
const { viewer, source, coverage } = setup();
|
||||||
|
coverage.refresh();
|
||||||
|
source.calls[0].reject(
|
||||||
|
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
viewer.view.lon += 0.05;
|
||||||
|
coverage.refresh();
|
||||||
|
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
|
||||||
|
t.mock.timers.tick(28_000);
|
||||||
|
viewer.view.lon += 0.05;
|
||||||
|
coverage.refresh();
|
||||||
|
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
||||||
|
coverage.clear();
|
||||||
|
coverage.resetErrors();
|
||||||
|
});
|
||||||
@@ -36,8 +36,11 @@ export function createSelection({ state, parts }) {
|
|||||||
*/
|
*/
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||||
|
// A text field keeps its Esc, rich-text editors (contenteditable) included.
|
||||||
|
const target = event.target;
|
||||||
if (
|
if (
|
||||||
event.target?.closest?.(
|
target?.isContentEditable === true ||
|
||||||
|
target?.closest?.(
|
||||||
'.panel-collapsible, [role="dialog"], input, textarea, select',
|
'.panel-collapsible, [role="dialog"], input, textarea, select',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { createSelection } from './selection.js';
|
|||||||
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
||||||
* has a known timeline.
|
* has a known timeline.
|
||||||
*/
|
*/
|
||||||
function harness() {
|
function harness({ selected = false } = {}) {
|
||||||
const saved = {
|
const saved = {
|
||||||
document: globalThis.document,
|
document: globalThis.document,
|
||||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
@@ -17,6 +17,23 @@ function harness() {
|
|||||||
const frames = [];
|
const frames = [];
|
||||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||||
globalThis.document = new EventTarget();
|
globalThis.document = new EventTarget();
|
||||||
|
// The document's keydown listeners, so a test can hand them any target.
|
||||||
|
const keyListeners = new Set();
|
||||||
|
const addListener = globalThis.document.addEventListener.bind(
|
||||||
|
globalThis.document,
|
||||||
|
);
|
||||||
|
const removeListener = globalThis.document.removeEventListener.bind(
|
||||||
|
globalThis.document,
|
||||||
|
);
|
||||||
|
globalThis.document.addEventListener = (type, listener, options) => {
|
||||||
|
if (type === 'keydown') keyListeners.add(listener);
|
||||||
|
addListener(type, listener, options);
|
||||||
|
};
|
||||||
|
globalThis.document.removeEventListener = (type, listener, options) => {
|
||||||
|
if (type === 'keydown') keyListeners.delete(listener);
|
||||||
|
removeListener(type, listener, options);
|
||||||
|
};
|
||||||
|
const sequences = { selected, cleared: 0 };
|
||||||
const picks = [];
|
const picks = [];
|
||||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||||
const scene = { under: null };
|
const scene = { under: null };
|
||||||
@@ -48,8 +65,11 @@ function harness() {
|
|||||||
state,
|
state,
|
||||||
parts: {
|
parts: {
|
||||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||||
hasSelectedSequence: () => false,
|
hasSelectedSequence: () => sequences.selected,
|
||||||
clearSequences() {},
|
clearSequences() {
|
||||||
|
sequences.cleared++;
|
||||||
|
sequences.selected = false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
selection.install(viewer);
|
selection.install(viewer);
|
||||||
@@ -58,6 +78,20 @@ function harness() {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
sequences,
|
||||||
|
/** A keydown reaching the document from `target`; the event as handled. */
|
||||||
|
key(key, target, { defaultPrevented = false } = {}) {
|
||||||
|
const event = {
|
||||||
|
key,
|
||||||
|
target,
|
||||||
|
defaultPrevented,
|
||||||
|
preventDefault() {
|
||||||
|
this.defaultPrevented = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const listener of [...keyListeners]) listener(event);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
scene,
|
scene,
|
||||||
canvas,
|
canvas,
|
||||||
camera,
|
camera,
|
||||||
@@ -168,3 +202,58 @@ test('a hover frame queued before the layer went off does not pick', () => {
|
|||||||
h.restore();
|
h.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An element as Esc sees it: `closest` matches the tag names a selector list
|
||||||
|
* names, and `isContentEditable` is what the browser reports, inherited from
|
||||||
|
* an editing host.
|
||||||
|
*/
|
||||||
|
function element(tag, { isContentEditable = false } = {}) {
|
||||||
|
return {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
isContentEditable,
|
||||||
|
closest(selector) {
|
||||||
|
const names = selector.split(',').map((part) => part.trim());
|
||||||
|
return names.includes(tag) ? this : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Esc on the globe clears the selected sequence', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
const event = h.key('Escape', element('canvas'));
|
||||||
|
assert.equal(h.sequences.cleared, 1);
|
||||||
|
assert.equal(event.defaultPrevented, true, 'and claims the key');
|
||||||
|
h.key('Escape', element('canvas'));
|
||||||
|
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an Esc something else already handled keeps the selection (M60)', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
h.key('Escape', element('canvas'), { defaultPrevented: true });
|
||||||
|
assert.equal(h.sequences.cleared, 0);
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
for (const tag of ['input', 'textarea', 'select']) {
|
||||||
|
const event = h.key('Escape', element(tag));
|
||||||
|
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
|
||||||
|
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
|
||||||
|
}
|
||||||
|
// A <div contenteditable> (or anything inside one) is a text field too.
|
||||||
|
h.key('Escape', element('div', { isContentEditable: true }));
|
||||||
|
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -108,6 +108,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si
|
|||||||
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
|
||||||
|
const z = 14;
|
||||||
|
const n = 2 ** z;
|
||||||
|
const bbox = [179.9, 0, -179.9, 0.1];
|
||||||
|
const key = (t) => `${t.x}/${t.y}`;
|
||||||
|
const keys = (list) => new Set(list.map(key));
|
||||||
|
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||||
|
const plain = tilesForBbox(bbox, z);
|
||||||
|
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
||||||
|
// The box centre is the date line itself, not the middle of the map: the
|
||||||
|
// middle two rows of the columns touching it come first, then the ring
|
||||||
|
// around them, all within two columns of the line.
|
||||||
|
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
||||||
|
assert.deepEqual(
|
||||||
|
keys(plain.tiles.slice(0, 4)),
|
||||||
|
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
||||||
|
);
|
||||||
|
for (const tile of plain.tiles.slice(4, 12))
|
||||||
|
assert.ok(
|
||||||
|
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||||
|
`${key(tile)} near the line`,
|
||||||
|
);
|
||||||
|
// A line of sight across the date line just north of the equator: the two
|
||||||
|
// tiles it crosses come first, then the two in the row below them.
|
||||||
|
const row = latToTileY(0.05, z);
|
||||||
|
const from = { lon: 179.99, lat: 0.05 };
|
||||||
|
const to = { lon: -179.99, lat: 0.05 };
|
||||||
|
for (const focus of [
|
||||||
|
{ from, to },
|
||||||
|
{ from: to, to: from },
|
||||||
|
]) {
|
||||||
|
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
|
||||||
|
assert.deepEqual(
|
||||||
|
keys(ranked.slice(0, 2)),
|
||||||
|
new Set([`${n - 1}/${row}`, `0/${row}`]),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
keys(ranked.slice(2, 4)),
|
||||||
|
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
|
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
|
||||||
// A box that runs far north of the camera, as a tilted view's does: the
|
// A box that runs far north of the camera, as a tilted view's does: the
|
||||||
// box centre is kilometres from both the camera and the screen centre.
|
// box centre is kilometres from both the camera and the screen centre.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
cameraHeightAboveGround,
|
cameraHeightAboveGround,
|
||||||
|
createHorizonCull,
|
||||||
metresBetween,
|
metresBetween,
|
||||||
viewCentre,
|
viewCentre,
|
||||||
viewFocus,
|
viewFocus,
|
||||||
@@ -167,3 +168,100 @@ test('metresBetween measures the short way round the date line (review IC8 P2)',
|
|||||||
) < 1e-9,
|
) < 1e-9,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizon cull over `items` for a camera the test moves, with the frame's
|
||||||
|
* pre-render listeners and a count of the points tested against the horizon.
|
||||||
|
*/
|
||||||
|
function cullHarness(t, items) {
|
||||||
|
const visible = t.mock.method(
|
||||||
|
Cesium.EllipsoidalOccluder.prototype,
|
||||||
|
'isPointVisible',
|
||||||
|
);
|
||||||
|
const preRender = new Set();
|
||||||
|
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
|
||||||
|
const viewer = {
|
||||||
|
camera,
|
||||||
|
scene: {
|
||||||
|
preRender: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
preRender.add(listener);
|
||||||
|
return () => preRender.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let changes = 0;
|
||||||
|
const cull = createHorizonCull({
|
||||||
|
getViewer: () => viewer,
|
||||||
|
items: () => items,
|
||||||
|
onChange: () => changes++,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cull,
|
||||||
|
preRender,
|
||||||
|
tested: () => visible.mock.callCount(),
|
||||||
|
changes: () => changes,
|
||||||
|
frame: () => {
|
||||||
|
for (const listener of [...preRender]) listener();
|
||||||
|
},
|
||||||
|
// Moved in place, as Cesium updates positionWC.
|
||||||
|
moveTo: (lon, lat) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
1e7,
|
||||||
|
undefined,
|
||||||
|
camera.positionWC,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = (lon, lat) => ({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horizon cull does no work while the camera stands still', (t) => {
|
||||||
|
const near = point(0, 0);
|
||||||
|
const far = point(180, 0);
|
||||||
|
const h = cullHarness(t, [near, far]);
|
||||||
|
h.cull.update();
|
||||||
|
assert.equal(h.tested(), 2);
|
||||||
|
assert.deepEqual([near.show, far.show], [true, false]);
|
||||||
|
assert.equal(h.preRender.size, 1, 'listening for camera moves');
|
||||||
|
for (let i = 0; i < 5; i++) h.frame();
|
||||||
|
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
|
||||||
|
h.moveTo(180, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
|
||||||
|
assert.deepEqual([near.show, far.show], [false, true]);
|
||||||
|
h.frame();
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.tested(), 4, 'and then rests again');
|
||||||
|
h.cull.stop();
|
||||||
|
assert.equal(h.preRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
|
||||||
|
const items = [point(0, 0)];
|
||||||
|
const h = cullHarness(t, items);
|
||||||
|
h.cull.update();
|
||||||
|
assert.equal(h.preRender.size, 1);
|
||||||
|
// Every item goes (the tiles are dropped); the next camera move finds none.
|
||||||
|
items.length = 0;
|
||||||
|
h.moveTo(10, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.preRender.size, 0, 'the listener is removed');
|
||||||
|
// New items arrive: they are culled now, and camera moves are watched again.
|
||||||
|
const far = point(-170, 0);
|
||||||
|
items.push(far);
|
||||||
|
h.cull.update([far]);
|
||||||
|
assert.equal(far.show, false, 'culled on arrival');
|
||||||
|
assert.equal(h.preRender.size, 1, 'the listener is back');
|
||||||
|
h.moveTo(-170, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(far.show, true, 're-culled when the camera moves');
|
||||||
|
assert.equal(h.changes(), 2, 'a redraw for each change');
|
||||||
|
h.cull.stop();
|
||||||
|
});
|
||||||
@@ -30,6 +30,11 @@ registerHooks({
|
|||||||
const { createMapillaryViewer } =
|
const { createMapillaryViewer } =
|
||||||
await import('./providers/mapillary/viewer.js');
|
await import('./providers/mapillary/viewer.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A viewer host over `adapter`. `marker` records where the position marker
|
||||||
|
* was put (null once cleared), `followed` counts camera-follow updates and
|
||||||
|
* `selected` every sequence the provider was asked to select.
|
||||||
|
*/
|
||||||
function harness(adapter) {
|
function harness(adapter) {
|
||||||
const state = {
|
const state = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -38,17 +43,44 @@ function harness(adapter) {
|
|||||||
street: { host: {}, renderMode: 'letterbox', follow: false },
|
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||||
};
|
};
|
||||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||||
state.providers.set('mapillary', { def, instance: { viewer: adapter } });
|
const selected = [];
|
||||||
|
let selectedId = null;
|
||||||
|
const instance = {
|
||||||
|
viewer: adapter,
|
||||||
|
selectSequence(id) {
|
||||||
|
selected.push(id);
|
||||||
|
selectedId = id;
|
||||||
|
},
|
||||||
|
sequenceStats: () => ({ selectedId }),
|
||||||
|
};
|
||||||
|
state.providers.set('mapillary', { def, instance });
|
||||||
const framing = { started: 0, cancelled: 0 };
|
const framing = { started: 0, cancelled: 0 };
|
||||||
|
const marker = [];
|
||||||
|
const followed = { count: 0 };
|
||||||
const parts = {
|
const parts = {
|
||||||
marker: { set() {}, clear() {} },
|
marker: {
|
||||||
|
set: (position, bearing) =>
|
||||||
|
marker.push(position && { position, bearing }),
|
||||||
|
clear: () => marker.push(null),
|
||||||
|
},
|
||||||
follow: {
|
follow: {
|
||||||
followCamera() {},
|
followCamera: () => followed.count++,
|
||||||
lookAtPosition: () => framing.started++,
|
lookAtPosition: () => framing.started++,
|
||||||
cancelFraming: () => framing.cancelled++,
|
cancelFraming: () => framing.cancelled++,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return { state, framing, host: createViewerHost({ state, parts }) };
|
return {
|
||||||
|
state,
|
||||||
|
framing,
|
||||||
|
marker,
|
||||||
|
followed,
|
||||||
|
selected,
|
||||||
|
/** Esc on the map: the provider drops its selected sequence. */
|
||||||
|
clearSequence() {
|
||||||
|
selectedId = null;
|
||||||
|
},
|
||||||
|
host: createViewerHost({ state, parts }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +88,7 @@ function harness(adapter) {
|
|||||||
* construction (`gate` holds it open) and `unmount` destroys the viewer that
|
* construction (`gate` holds it open) and `unmount` destroys the viewer that
|
||||||
* every later `open` needs.
|
* every later `open` needs.
|
||||||
*/
|
*/
|
||||||
function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
||||||
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
||||||
let emit = null;
|
let emit = null;
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
@@ -70,7 +102,7 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
},
|
},
|
||||||
/** A pose event from the library, as MapillaryJS fires them. */
|
/** A pose event from the library, as MapillaryJS fires them. */
|
||||||
emitPose(id) {
|
emitPose(id, overrides = {}) {
|
||||||
emit?.({
|
emit?.({
|
||||||
providerId: 'mapillary',
|
providerId: 'mapillary',
|
||||||
imageId: id,
|
imageId: id,
|
||||||
@@ -79,12 +111,15 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
|||||||
bearing: 90,
|
bearing: 90,
|
||||||
isPano: false,
|
isPano: false,
|
||||||
externalUrl: `https://example.test/${id}`,
|
externalUrl: `https://example.test/${id}`,
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
/** Like MapillaryJS: the image event comes first, then `moveTo` settles. */
|
||||||
async open(id) {
|
async open(id) {
|
||||||
if (!mounted) throw new Error('viewer is not mounted');
|
if (!mounted) throw new Error('viewer is not mounted');
|
||||||
calls.open.push(id);
|
calls.open.push(id);
|
||||||
this.emitPose(id);
|
this.emitPose(id);
|
||||||
|
await openGate?.promise;
|
||||||
},
|
},
|
||||||
close() {},
|
close() {},
|
||||||
unmount() {
|
unmount() {
|
||||||
@@ -220,10 +255,115 @@ test('switching the layer off and on during a cold first open keeps the shared v
|
|||||||
assert.equal(state.street.imageId, 'c');
|
assert.equal(state.street.imageId, 'c');
|
||||||
});
|
});
|
||||||
|
|
||||||
/** MapillaryJS, counting live viewers (each holds a WebGL context). */
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
test('every pose the viewer reports moves the marker and the followed camera', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { state, host, marker, followed } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'a');
|
||||||
|
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||||
|
assert.equal(followed.count, 1);
|
||||||
|
// The user walks on, with FOLLOW on: the globe camera goes along.
|
||||||
|
state.street.follow = true;
|
||||||
|
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||||
|
assert.deepEqual(marker.at(-1), {
|
||||||
|
position: { lon: 3, lat: 4 },
|
||||||
|
bearing: 180,
|
||||||
|
});
|
||||||
|
assert.equal(followed.count, 2);
|
||||||
|
// 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.equal(followed.count, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
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() {
|
function fakeLibrary() {
|
||||||
const gate = deferred();
|
const gate = deferred();
|
||||||
const viewers = { created: 0, live: 0, instances: [] };
|
const viewers = {
|
||||||
|
created: 0,
|
||||||
|
live: 0,
|
||||||
|
instances: [],
|
||||||
|
moveGates: new Map(),
|
||||||
|
moves: [],
|
||||||
|
positionGate: null,
|
||||||
|
};
|
||||||
class Viewer {
|
class Viewer {
|
||||||
constructor() {
|
constructor() {
|
||||||
viewers.created++;
|
viewers.created++;
|
||||||
@@ -231,21 +371,27 @@ function fakeLibrary() {
|
|||||||
viewers.instances.push(this);
|
viewers.instances.push(this);
|
||||||
this.removed = false;
|
this.removed = false;
|
||||||
this.playback = { stops: 0 };
|
this.playback = { stops: 0 };
|
||||||
|
this.handlers = {};
|
||||||
}
|
}
|
||||||
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
|
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
|
||||||
getComponent(name) {
|
getComponent(name) {
|
||||||
if (name !== 'sequence') return undefined;
|
if (name !== 'sequence') return undefined;
|
||||||
return { stop: () => this.playback.stops++ };
|
return { stop: () => this.playback.stops++ };
|
||||||
}
|
}
|
||||||
on() {}
|
on(type, handler) {
|
||||||
|
this.handlers[type] = handler;
|
||||||
|
}
|
||||||
async getPosition() {
|
async getPosition() {
|
||||||
|
await viewers.positionGate?.promise;
|
||||||
return { lng: 1, lat: 2 };
|
return { lng: 1, lat: 2 };
|
||||||
}
|
}
|
||||||
async getPointOfView() {
|
async getPointOfView() {
|
||||||
return { bearing: 10, tilt: 0 };
|
return { bearing: 10, tilt: 0 };
|
||||||
}
|
}
|
||||||
async moveTo(id) {
|
async moveTo(id) {
|
||||||
return { id, cameraType: 'perspective', sequenceId: 's' };
|
viewers.moves.push(id);
|
||||||
|
await viewers.moveGates.get(id)?.promise;
|
||||||
|
return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
|
||||||
}
|
}
|
||||||
remove() {
|
remove() {
|
||||||
if (this.removed) return;
|
if (this.removed) return;
|
||||||
@@ -337,3 +483,67 @@ test('closing the photo or switching the layer off stops its framing flight (rev
|
|||||||
host.unmount();
|
host.unmount();
|
||||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -13,7 +13,9 @@ import * as tiles from '../../server/providers/mapillary/tiles.js';
|
|||||||
import {
|
import {
|
||||||
TILE_DISK_FILE_OVERHEAD_BYTES,
|
TILE_DISK_FILE_OVERHEAD_BYTES,
|
||||||
TILE_MAX_BYTES,
|
TILE_MAX_BYTES,
|
||||||
|
TILE_MEMORY_BUDGET_BYTES,
|
||||||
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
|
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
|
||||||
|
TILE_RATE_LIMIT_MAX_HOLD_MS,
|
||||||
TILE_ROUTE_MAX_PER_MIN,
|
TILE_ROUTE_MAX_PER_MIN,
|
||||||
TILE_UPSTREAM_CONCURRENCY,
|
TILE_UPSTREAM_CONCURRENCY,
|
||||||
} from '../../server/providers/mapillary/constants.js';
|
} from '../../server/providers/mapillary/constants.js';
|
||||||
@@ -259,6 +261,45 @@ test('a 429 passes its Retry-After on and holds misses until it is over (review
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const [why, retryAfter] of [
|
||||||
|
['seconds', () => '999999'],
|
||||||
|
['an HTTP date', () => new Date(Date.now() + 365 * 24 * HOUR).toUTCString()],
|
||||||
|
])
|
||||||
|
test(`a far-off Retry-After (${why}) holds misses for at most TILE_RATE_LIMIT_MAX_HOLD_MS`, async (t) => {
|
||||||
|
const maxSec = TILE_RATE_LIMIT_MAX_HOLD_MS / 1000;
|
||||||
|
await withUpstream(
|
||||||
|
() =>
|
||||||
|
new Response('{}', {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': retryAfter() },
|
||||||
|
}),
|
||||||
|
async ({ calls, call }) => {
|
||||||
|
const first = await call('/api/mapillary/tiles', '/coverage/14/5/5');
|
||||||
|
assert.equal(first.statusCode, 429);
|
||||||
|
assert.equal(first.headers['retry-after'], String(maxSec));
|
||||||
|
assert.equal(json(first).retryAfter, maxSec);
|
||||||
|
const now = Date.now();
|
||||||
|
t.mock.method(
|
||||||
|
Date,
|
||||||
|
'now',
|
||||||
|
() => now + TILE_RATE_LIMIT_MAX_HOLD_MS - 5000,
|
||||||
|
);
|
||||||
|
const held = await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||||
|
assert.equal(held.statusCode, 429);
|
||||||
|
assert.ok(
|
||||||
|
json(held).retryAfter <= 5,
|
||||||
|
'the hold counts down from the cap',
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1, 'held: Mapillary is not asked again');
|
||||||
|
Date.now.mock.mockImplementation(
|
||||||
|
() => now + TILE_RATE_LIMIT_MAX_HOLD_MS + 1000,
|
||||||
|
);
|
||||||
|
await call('/api/mapillary/tiles', '/coverage/14/6/6');
|
||||||
|
assert.equal(calls.length, 2, 'asked again once the capped hold ends');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
||||||
@@ -601,6 +642,45 @@ test('the upstream tile fetch is cancelled once every waiter has left', async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a request arriving before a cancelled fetch has wound down starts a fresh one', async () => {
|
||||||
|
const tile = { layer: 'coverage', z: 13, x: 130, y: 100 };
|
||||||
|
await withDeferredUpstream([tile], async ({ body, settle }) => {
|
||||||
|
// A real fetch rejects some time after its abort; until then the
|
||||||
|
// cancelled flight is still registered for its tile.
|
||||||
|
const upstream = [];
|
||||||
|
globalThis.fetch = (url, { signal } = {}) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const call = { aborted: false };
|
||||||
|
call.release = () => resolve(new Response(body, { status: 200 }));
|
||||||
|
call.windDown = () => reject(signal.reason);
|
||||||
|
signal?.addEventListener('abort', () => (call.aborted = true), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
upstream.push(call);
|
||||||
|
});
|
||||||
|
const first = new AbortController();
|
||||||
|
const a = fetchTile(tile, { signal: first.signal });
|
||||||
|
await waitFor(() => upstream.length === 1, 'the upstream fetch');
|
||||||
|
first.abort();
|
||||||
|
await assert.rejects(a, { name: 'AbortError' });
|
||||||
|
assert.equal(upstream[0].aborted, true, 'the last waiter cancelled it');
|
||||||
|
// Joining the cancelled flight would end in its AbortError (a 502).
|
||||||
|
const b = fetchTile(tile);
|
||||||
|
await waitFor(() => upstream.length === 2, 'a fresh upstream fetch');
|
||||||
|
// The cancelled flight winding down must not unregister the fresh one.
|
||||||
|
upstream[0].windDown();
|
||||||
|
await settle();
|
||||||
|
const c = fetchTile(tile);
|
||||||
|
await settle();
|
||||||
|
assert.equal(upstream.length, 2, 'a third request joins the fresh fetch');
|
||||||
|
upstream[1].release();
|
||||||
|
const fresh = await b;
|
||||||
|
assert.equal(fresh.source, 'upstream');
|
||||||
|
assert.deepEqual(listTileLayers(fresh.bytes), ['sequence']);
|
||||||
|
assert.equal((await c).source, 'inflight');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Route: success path, refusals, gate and limiter (review gekh) ──
|
// ── Route: success path, refusals, gate and limiter (review gekh) ──
|
||||||
|
|
||||||
/** Every header value and the body, as text, for a token-leak check. */
|
/** Every header value and the body, as text, for a token-leak check. */
|
||||||
@@ -1012,6 +1092,126 @@ test('empty tiles are charged against the memory budget (review gekh)', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A one-layer tile of exactly `size` bytes (a big opaque payload). */
|
||||||
|
function tileOfSize(size) {
|
||||||
|
const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]);
|
||||||
|
const bytes = tile([
|
||||||
|
{ name: 'sequence', payload: Buffer.alloc(2 * size - probe.length) },
|
||||||
|
]);
|
||||||
|
assert.equal(bytes.length, size);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve each address's big tile from a scripted upstream, with disk writes
|
||||||
|
* off: a tile memory dropped can only come back from Mapillary.
|
||||||
|
*/
|
||||||
|
async function withBigTiles(t, bodies, run) {
|
||||||
|
for (const address of bodies.keys())
|
||||||
|
await fsp.rm(tileFile(address), { force: true });
|
||||||
|
t.mock.method(fsp, 'writeFile', async () => {
|
||||||
|
throw new Error('disk writes are off in this test');
|
||||||
|
});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const byPath = new Map(
|
||||||
|
[...bodies].map(([{ z, x, y }, body]) => [`/2/${z}/${x}/${y}?`, body]),
|
||||||
|
);
|
||||||
|
await withUpstream(
|
||||||
|
(_n, url) => {
|
||||||
|
const [, body] = [...byPath].find(([key]) => url.includes(key));
|
||||||
|
return new Response(body, { status: 200 });
|
||||||
|
},
|
||||||
|
({ calls }) => {
|
||||||
|
const fetched = (address) =>
|
||||||
|
calls.filter((url) =>
|
||||||
|
url.includes(`/2/${address.z}/${address.x}/${address.y}?`),
|
||||||
|
).length;
|
||||||
|
return run({ fetched });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the memory cache evicts the least recently used tile, not the oldest fetched', async (t) => {
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
// Three fit in the budget, a fourth does not.
|
||||||
|
const size = 30 * MB;
|
||||||
|
const cost = size + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
|
||||||
|
assert.ok(3 * cost <= TILE_MEMORY_BUDGET_BYTES);
|
||||||
|
assert.ok(4 * cost > TILE_MEMORY_BUDGET_BYTES);
|
||||||
|
const body = tileOfSize(size);
|
||||||
|
const [a, b, c, d] = [20, 21, 22, 23].map((x) => ({
|
||||||
|
layer: 'coverage',
|
||||||
|
z: 14,
|
||||||
|
x,
|
||||||
|
y: 20,
|
||||||
|
}));
|
||||||
|
await withBigTiles(
|
||||||
|
t,
|
||||||
|
new Map([a, b, c, d].map((address) => [address, body])),
|
||||||
|
async ({ fetched }) => {
|
||||||
|
for (const address of [a, b, c])
|
||||||
|
assert.equal((await fetchTile(address)).source, 'upstream');
|
||||||
|
assert.deepEqual(tiles._tileMemoryForTest(), {
|
||||||
|
entries: 3,
|
||||||
|
bytes: 3 * cost,
|
||||||
|
});
|
||||||
|
// A is used again, so B is now the least recently used.
|
||||||
|
assert.equal((await fetchTile(a)).source, 'memory');
|
||||||
|
assert.equal((await fetchTile(d)).source, 'upstream');
|
||||||
|
assert.deepEqual(tiles._tileMemoryForTest(), {
|
||||||
|
entries: 3,
|
||||||
|
bytes: 3 * cost,
|
||||||
|
});
|
||||||
|
for (const address of [a, c, d])
|
||||||
|
assert.equal(
|
||||||
|
(await fetchTile(address)).source,
|
||||||
|
'memory',
|
||||||
|
`${address.x} kept`,
|
||||||
|
);
|
||||||
|
// B was evicted; asking for it again goes back to Mapillary.
|
||||||
|
assert.equal((await fetchTile(b)).source, 'upstream');
|
||||||
|
assert.equal(fetched(b), 2);
|
||||||
|
assert.equal(fetched(a), 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tile costing more than half the memory budget is served but not kept', async (t) => {
|
||||||
|
const half = TILE_MEMORY_BUDGET_BYTES / 2;
|
||||||
|
// At exactly half the budget a tile is kept; one byte more and it is not.
|
||||||
|
const fits = tileOfSize(half - TILE_MEMORY_ENTRY_OVERHEAD_BYTES);
|
||||||
|
const tooBig = tileOfSize(half - TILE_MEMORY_ENTRY_OVERHEAD_BYTES + 1);
|
||||||
|
assert.ok(tooBig.length <= TILE_MAX_BYTES, 'within the upstream size cap');
|
||||||
|
const small = { layer: 'coverage', z: 14, x: 24, y: 20 };
|
||||||
|
const kept = { layer: 'coverage', z: 14, x: 25, y: 20 };
|
||||||
|
const big = { layer: 'coverage', z: 14, x: 26, y: 20 };
|
||||||
|
await withBigTiles(
|
||||||
|
t,
|
||||||
|
new Map([
|
||||||
|
[small, tile([{ name: 'sequence' }])],
|
||||||
|
[kept, fits],
|
||||||
|
[big, tooBig],
|
||||||
|
]),
|
||||||
|
async ({ fetched }) => {
|
||||||
|
await fetchTile(small);
|
||||||
|
const before = tiles._tileMemoryForTest();
|
||||||
|
const served = await fetchTile(big);
|
||||||
|
assert.equal(served.source, 'upstream');
|
||||||
|
assert.equal(served.bytes.length, tooBig.length, 'served in full');
|
||||||
|
assert.deepEqual(
|
||||||
|
tiles._tileMemoryForTest(),
|
||||||
|
before,
|
||||||
|
'nothing evicted to make room',
|
||||||
|
);
|
||||||
|
assert.equal((await fetchTile(small)).source, 'memory');
|
||||||
|
assert.equal((await fetchTile(big)).source, 'upstream', 'not kept');
|
||||||
|
assert.equal(fetched(big), 2);
|
||||||
|
assert.equal((await fetchTile(kept)).source, 'upstream');
|
||||||
|
assert.equal((await fetchTile(kept)).source, 'memory', 'half fits');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('empty tile files count toward the disk cap (review gekh)', async () => {
|
test('empty tile files count toward the disk cap (review gekh)', async () => {
|
||||||
await withCacheDir(async (dir) => {
|
await withCacheDir(async (dir) => {
|
||||||
const files = [3, 2, 1].map((age) => ({
|
const files = [3, 2, 1].map((age) => ({
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
|||||||
*/
|
*/
|
||||||
const mutations = { count: 0 };
|
const mutations = { count: 0 };
|
||||||
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
|
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
|
||||||
|
/** The reflected properties a MutationObserver sees, as the record it gets. */
|
||||||
|
const REFLECTED_RECORDS = {
|
||||||
|
hidden: { type: 'attributes', attributeName: 'hidden' },
|
||||||
|
disabled: { type: 'attributes', attributeName: 'disabled' },
|
||||||
|
title: { type: 'attributes', attributeName: 'title' },
|
||||||
|
textContent: { type: 'childList' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
|
||||||
|
const liveObservers = new Set();
|
||||||
|
|
||||||
|
/** Queue a mutation record for every observer whose options cover it. */
|
||||||
|
function recordMutation(target, { type, attributeName = null }) {
|
||||||
|
for (const observer of liveObservers)
|
||||||
|
observer.consider(target, type, attributeName);
|
||||||
|
}
|
||||||
|
|
||||||
class FakeNode {
|
class FakeNode {
|
||||||
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
|
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
|
||||||
@@ -42,18 +58,30 @@ class FakeNode {
|
|||||||
set: (value) => {
|
set: (value) => {
|
||||||
mutations.count++;
|
mutations.count++;
|
||||||
this.props[key] = value;
|
this.props[key] = value;
|
||||||
|
if (REFLECTED_RECORDS[key])
|
||||||
|
recordMutation(this, REFLECTED_RECORDS[key]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.attributes = new Map();
|
this.attributes = new Map();
|
||||||
const names = new Set(classes);
|
const names = new Set(classes);
|
||||||
|
// Every DOMTokenList write sets the class attribute, changed or not.
|
||||||
|
const classChanged = () =>
|
||||||
|
recordMutation(this, { type: 'attributes', attributeName: 'class' });
|
||||||
this.classList = {
|
this.classList = {
|
||||||
add: (name) => names.add(name),
|
add: (name) => {
|
||||||
remove: (name) => names.delete(name),
|
names.add(name);
|
||||||
|
classChanged();
|
||||||
|
},
|
||||||
|
remove: (name) => {
|
||||||
|
names.delete(name);
|
||||||
|
classChanged();
|
||||||
|
},
|
||||||
contains: (name) => names.has(name),
|
contains: (name) => names.has(name),
|
||||||
toggle: (name, force) => {
|
toggle: (name, force) => {
|
||||||
const on = force ?? !names.has(name);
|
const on = force ?? !names.has(name);
|
||||||
if (on) names.add(name);
|
if (on) names.add(name);
|
||||||
else names.delete(name);
|
else names.delete(name);
|
||||||
|
classChanged();
|
||||||
return on;
|
return on;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -73,32 +101,59 @@ class FakeNode {
|
|||||||
names.clear();
|
names.clear();
|
||||||
for (const name of String(value).split(/\s+/).filter(Boolean))
|
for (const name of String(value).split(/\s+/).filter(Boolean))
|
||||||
names.add(name);
|
names.add(name);
|
||||||
|
classChanged();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
get childElementCount() {
|
get childElementCount() {
|
||||||
return this.children.length;
|
return this.children.length;
|
||||||
}
|
}
|
||||||
|
get parentNode() {
|
||||||
|
return this.parent;
|
||||||
|
}
|
||||||
|
get nextSibling() {
|
||||||
|
if (!this.parent) return null;
|
||||||
|
const siblings = this.parent.children;
|
||||||
|
return siblings[siblings.indexOf(this) + 1] || null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* As layout reports it: null when this node or an ancestor is hidden (no
|
||||||
|
* box), or when it is not in the document; else its parent.
|
||||||
|
*/
|
||||||
|
get offsetParent() {
|
||||||
|
if (!this.isConnected) return null;
|
||||||
|
for (let node = this; node; node = node.parent)
|
||||||
|
if (node.hidden) return null;
|
||||||
|
return this.parent;
|
||||||
|
}
|
||||||
setAttribute(key, value) {
|
setAttribute(key, value) {
|
||||||
mutations.count++;
|
mutations.count++;
|
||||||
this.attributes.set(key, String(value));
|
this.attributes.set(key, String(value));
|
||||||
|
recordMutation(this, { type: 'attributes', attributeName: key });
|
||||||
}
|
}
|
||||||
getAttribute(key) {
|
getAttribute(key) {
|
||||||
return this.attributes.get(key) ?? null;
|
return this.attributes.get(key) ?? null;
|
||||||
}
|
}
|
||||||
removeAttribute(key) {
|
removeAttribute(key) {
|
||||||
if (this.attributes.delete(key)) mutations.count++;
|
if (!this.attributes.delete(key)) return;
|
||||||
|
mutations.count++;
|
||||||
|
recordMutation(this, { type: 'attributes', attributeName: key });
|
||||||
}
|
}
|
||||||
appendChild(child) {
|
appendChild(child) {
|
||||||
return this.insertBefore(child, null);
|
return this.insertBefore(child, null);
|
||||||
}
|
}
|
||||||
|
/** Like the DOM's: a reference that is not a child throws NotFoundError. */
|
||||||
insertBefore(child, reference) {
|
insertBefore(child, reference) {
|
||||||
|
if (reference != null && reference.parent !== this)
|
||||||
|
throw new DOMException('not a child of this node', 'NotFoundError');
|
||||||
|
if (reference === child) return child;
|
||||||
child.remove();
|
child.remove();
|
||||||
const index = reference
|
const index = reference
|
||||||
? this.children.indexOf(reference)
|
? this.children.indexOf(reference)
|
||||||
: this.children.length;
|
: this.children.length;
|
||||||
this.children.splice(index < 0 ? this.children.length : index, 0, child);
|
this.children.splice(index, 0, child);
|
||||||
child.parent = this;
|
child.parent = this;
|
||||||
|
recordMutation(this, { type: 'childList' });
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
append(...nodes) {
|
append(...nodes) {
|
||||||
@@ -109,9 +164,11 @@ class FakeNode {
|
|||||||
this.append(...nodes);
|
this.append(...nodes);
|
||||||
}
|
}
|
||||||
remove() {
|
remove() {
|
||||||
if (!this.parent) return;
|
const parent = this.parent;
|
||||||
this.parent.children.splice(this.parent.children.indexOf(this), 1);
|
if (!parent) return;
|
||||||
|
parent.children.splice(parent.children.indexOf(this), 1);
|
||||||
this.parent = null;
|
this.parent = null;
|
||||||
|
recordMutation(parent, { type: 'childList' });
|
||||||
}
|
}
|
||||||
contains(node) {
|
contains(node) {
|
||||||
for (let current = node; current; current = current.parent)
|
for (let current = node; current; current = current.parent)
|
||||||
@@ -272,7 +329,7 @@ function panelDom() {
|
|||||||
globe.setAttribute('tabindex', '0');
|
globe.setAttribute('tabindex', '0');
|
||||||
// A field in another panel (the location search).
|
// A field in another panel (the location search).
|
||||||
const search = add(document.body, 'input', { id: 'location-search' });
|
const search = add(document.body, 'input', { id: 'location-search' });
|
||||||
return { document, root, globe, search };
|
return { document, root, globe, search, main, wrap, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run `fn` with the fake document and an immediate animation frame. */
|
/** Run `fn` with the fake document and an immediate animation frame. */
|
||||||
@@ -602,6 +659,11 @@ test('Esc shrinks the expanded viewer after focus left it, and the map keeps its
|
|||||||
test('Tab from outside the expanded viewer brings focus back into it', () =>
|
test('Tab from outside the expanded viewer brings focus back into it', () =>
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const { controls } = stubPanel(dom, uiState({ open: true }));
|
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
|
// A tool hidden in this state (no box, so not a tab stop) ends the bar.
|
||||||
|
const hiddenTool = dom.wrap.appendChild(
|
||||||
|
new FakeNode(dom.document, 'button', { id: 'sl-hidden-tool' }),
|
||||||
|
);
|
||||||
|
hiddenTool.hidden = true;
|
||||||
controls.setViewerExpanded(true);
|
controls.setViewerExpanded(true);
|
||||||
dom.globe.focus();
|
dom.globe.focus();
|
||||||
keydown(dom.globe, 'Tab');
|
keydown(dom.globe, 'Tab');
|
||||||
@@ -614,7 +676,13 @@ test('Tab from outside the expanded viewer brings focus back into it', () =>
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
dom.document.activeElement,
|
dom.document.activeElement,
|
||||||
wrap.querySelector('#sl-viewer-close'),
|
wrap.querySelector('#sl-viewer-close'),
|
||||||
'Shift+Tab wraps to the last control',
|
'Shift+Tab wraps to the last control, past the hidden one',
|
||||||
|
);
|
||||||
|
keydown(dom.document.activeElement, 'Tab');
|
||||||
|
assert.equal(
|
||||||
|
dom.document.activeElement,
|
||||||
|
wrap.querySelector('#sl-viewer-expand'),
|
||||||
|
'Tab from the last shown control wraps to the first',
|
||||||
);
|
);
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
}));
|
}));
|
||||||
@@ -742,7 +810,12 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
|
|||||||
controls.destroy();
|
controls.destroy();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** A MutationObserver stand-in the test fires by hand. */
|
/**
|
||||||
|
* A MutationObserver stand-in. Like the real one it queues only the records
|
||||||
|
* its `observe` options cover (childList, attributes / attributeFilter,
|
||||||
|
* subtree) and delivers them in a microtask, only when there are some;
|
||||||
|
* `fire()` delivers what is queued at once.
|
||||||
|
*/
|
||||||
function fakeMutationObserver() {
|
function fakeMutationObserver() {
|
||||||
const observers = [];
|
const observers = [];
|
||||||
class FakeMutationObserver {
|
class FakeMutationObserver {
|
||||||
@@ -750,19 +823,46 @@ function fakeMutationObserver() {
|
|||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.target = null;
|
this.target = null;
|
||||||
this.options = null;
|
this.options = null;
|
||||||
|
this.records = [];
|
||||||
observers.push(this);
|
observers.push(this);
|
||||||
}
|
}
|
||||||
observe(target, options) {
|
observe(target, options = {}) {
|
||||||
this.target = target;
|
this.target = target;
|
||||||
this.options = options;
|
this.options = {
|
||||||
|
...options,
|
||||||
|
// Per the spec, an attribute filter implies `attributes`.
|
||||||
|
attributes: options.attributes ?? Boolean(options.attributeFilter),
|
||||||
|
};
|
||||||
|
liveObservers.add(this);
|
||||||
}
|
}
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this.target = null;
|
this.target = null;
|
||||||
|
this.records = [];
|
||||||
|
liveObservers.delete(this);
|
||||||
|
}
|
||||||
|
consider(node, type, attributeName) {
|
||||||
|
const { childList, attributes, attributeFilter, subtree } = this.options;
|
||||||
|
const inScope =
|
||||||
|
node === this.target ||
|
||||||
|
(subtree === true && this.target.contains(node));
|
||||||
|
if (!inScope) return;
|
||||||
|
if (type === 'childList' && childList !== true) return;
|
||||||
|
if (type === 'attributes') {
|
||||||
|
if (attributes !== true) return;
|
||||||
|
if (attributeFilter && !attributeFilter.includes(attributeName)) return;
|
||||||
|
}
|
||||||
|
this.records.push({ type, target: node, attributeName });
|
||||||
|
if (this.records.length === 1) queueMicrotask(() => this.deliver());
|
||||||
|
}
|
||||||
|
deliver() {
|
||||||
|
if (!this.target || !this.records.length) return;
|
||||||
|
const records = this.records;
|
||||||
|
this.records = [];
|
||||||
|
this.callback(records, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const fire = () => {
|
const fire = () => {
|
||||||
for (const observer of observers)
|
for (const observer of observers) observer.deliver();
|
||||||
if (observer.target) observer.callback([], observer);
|
|
||||||
};
|
};
|
||||||
const watching = () => observers.some((observer) => observer.target);
|
const watching = () => observers.some((observer) => observer.target);
|
||||||
return { FakeMutationObserver, fire, watching };
|
return { FakeMutationObserver, fire, watching };
|
||||||
@@ -776,6 +876,7 @@ async function withObserver(fn) {
|
|||||||
try {
|
try {
|
||||||
return await fn(fake);
|
return await fn(fake);
|
||||||
} finally {
|
} finally {
|
||||||
|
liveObservers.clear();
|
||||||
if (saved === undefined) delete globalThis.MutationObserver;
|
if (saved === undefined) delete globalThis.MutationObserver;
|
||||||
else globalThis.MutationObserver = saved;
|
else globalThis.MutationObserver = saved;
|
||||||
}
|
}
|
||||||
@@ -970,3 +1071,82 @@ test('under KEY REQUIRED the provider chip stays live and explains the key inste
|
|||||||
assert.match(calls.toasts[0], /^Mapillary: Needs /);
|
assert.match(calls.toasts[0], /^Mapillary: Needs /);
|
||||||
controls.destroy();
|
controls.destroy();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/* ── The expanded viewer goes back where it came from ──────────────────── */
|
||||||
|
|
||||||
|
/** A node by name, so a failed comparison prints a word, not the DOM. */
|
||||||
|
const nameOf = (node) =>
|
||||||
|
node ? node.id || node.className || node.tagName.toLowerCase() : null;
|
||||||
|
|
||||||
|
/** Where the viewer sits: its parent and the sibling it sits before. */
|
||||||
|
const placeOf = (wrap) => [nameOf(wrap.parentNode), nameOf(wrap.nextSibling)];
|
||||||
|
const PANEL_PLACE = ['sl-main', 'sl-settings'];
|
||||||
|
|
||||||
|
test('shrinking returns the viewer into the panel at its own place', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
|
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||||
|
controls.setViewerExpanded(true);
|
||||||
|
assert.equal(nameOf(dom.wrap.parentNode), 'body', 'lifted out');
|
||||||
|
controls.setViewerExpanded(false);
|
||||||
|
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE, 'before the settings');
|
||||||
|
assert.equal(dom.main.children.indexOf(dom.wrap), 0);
|
||||||
|
assert.equal(dom.root.contains(dom.wrap), true);
|
||||||
|
// A second round trip lands in the same place.
|
||||||
|
controls.setViewerExpanded(true);
|
||||||
|
controls.setViewerExpanded(false);
|
||||||
|
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('an image closed while expanded returns the viewer into the panel', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
|
controls.setViewerExpanded(true);
|
||||||
|
layer.publish(uiState({ open: false }));
|
||||||
|
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||||
|
assert.equal(dom.wrap.hidden, true, 'and hidden there');
|
||||||
|
controls.destroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('destroying the panel while expanded returns the viewer into it', () =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
|
controls.setViewerExpanded(true);
|
||||||
|
controls.destroy();
|
||||||
|
assert.deepEqual(placeOf(dom.wrap), PANEL_PLACE);
|
||||||
|
assert.equal(
|
||||||
|
dom.document.body.children.includes(dom.wrap),
|
||||||
|
false,
|
||||||
|
'nothing left on <body>',
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('a <body> child added while the viewer is expanded goes inert with the rest', () =>
|
||||||
|
withObserver((observer) =>
|
||||||
|
withDom(async (dom) => {
|
||||||
|
const { controls } = stubPanel(dom, uiState({ open: true }));
|
||||||
|
controls.setViewerExpanded(true);
|
||||||
|
// A toast, a menu or another panel's popup, appended later.
|
||||||
|
const toast = dom.document.body.appendChild(
|
||||||
|
new FakeNode(dom.document, 'div', { id: 'toast' }),
|
||||||
|
);
|
||||||
|
assert.equal(toast.inert, undefined, 'not before the observer runs');
|
||||||
|
await settle(); // records are delivered in a microtask
|
||||||
|
assert.equal(toast.inert, true);
|
||||||
|
// Changes inside the app are not the modal's business: nothing runs.
|
||||||
|
let syncs = 0;
|
||||||
|
const sync = controls._syncModal.bind(controls);
|
||||||
|
controls._syncModal = () => {
|
||||||
|
syncs++;
|
||||||
|
return sync();
|
||||||
|
};
|
||||||
|
dom.root.querySelector('#sl-status').setAttribute('class', 'x');
|
||||||
|
dom.root.appendChild(new FakeNode(dom.document, 'div'));
|
||||||
|
await settle();
|
||||||
|
assert.equal(syncs, 0);
|
||||||
|
controls.destroy();
|
||||||
|
assert.equal(toast.inert, false, 'destroy releases it too');
|
||||||
|
assert.equal(observer.watching(), false);
|
||||||
|
}),
|
||||||
|
));
|
||||||
@@ -100,6 +100,23 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8
|
|||||||
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
|
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
|
||||||
|
// A keyless provider can still report loading (its status check, a retry):
|
||||||
|
// the pill must say what is wrong, not that something is on its way.
|
||||||
|
const loading = { enabled: true, coverage: { loading: true } };
|
||||||
|
assert.equal(
|
||||||
|
presentStreetLevelPanel(snapshot({ ...loading, keyRequired: true })).status
|
||||||
|
.text,
|
||||||
|
'KEY REQUIRED',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
presentStreetLevelPanel(
|
||||||
|
snapshot({ ...loading, keyRequired: true, keyRejected: true }),
|
||||||
|
).status.text,
|
||||||
|
'KEY REJECTED',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
|
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
|
||||||
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
|
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
|
||||||
text: 'OFF',
|
text: 'OFF',
|
||||||
|
|||||||
Reference in new issue
Block a user