diff --git a/src/data/layerState.js b/src/data/layerState.js
index e9f5d26a8..2c152d143 100644
--- a/src/data/layerState.js
+++ b/src/data/layerState.js
@@ -419,6 +419,8 @@ const OPTION_GROUPS = Object.freeze({
flat: 'f',
}),
// "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 }),
]),
radio: Object.freeze([
diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs
index b9816f7d8..956b99a10 100644
--- a/src/data/layerState.test.mjs
+++ b/src/data/layerState.test.mjs
@@ -26,6 +26,8 @@ import {
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
+import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
+import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
function deferred() {
let resolve;
@@ -2642,3 +2644,63 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
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,
+ );
+});
diff --git a/src/layers/streetLevel/filter.test.mjs b/src/layers/streetLevel/filter.test.mjs
index e2f3a1c3a..749031036 100644
--- a/src/layers/streetLevel/filter.test.mjs
+++ b/src/layers/streetLevel/filter.test.mjs
@@ -6,6 +6,7 @@ import {
resolveFilter,
sameFilter,
} from './filter.js';
+import { MAX_SINCE_DAYS } from './policy.js';
const DAY = 86_400_000;
@@ -81,3 +82,20 @@ test('sameFilter compares the stored form', () => {
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',
+ );
+});
diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs
index 47429a929..f668f494b 100644
--- a/src/layers/streetLevel/index.test.mjs
+++ b/src/layers/streetLevel/index.test.mjs
@@ -419,3 +419,66 @@ test('the Mapillary provider hands the lookup signal to its source (review IC8 P
);
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);
+});
diff --git a/src/layers/streetLevel/marker.test.mjs b/src/layers/streetLevel/marker.test.mjs
index c20fc58c0..a13c6b4bd 100644
--- a/src/layers/streetLevel/marker.test.mjs
+++ b/src/layers/streetLevel/marker.test.mjs
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
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.
const noop = () => {};
@@ -11,7 +12,7 @@ globalThis.document ??= {
};
/** 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 preRender = new Set();
const viewer = {
@@ -33,7 +34,7 @@ function setup() {
const state = {
services: {},
viewer,
- surface: 'draped',
+ surface,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
@@ -44,7 +45,7 @@ function setup() {
billboard: null,
},
};
- const marker = createMarker({ state, parts: {} });
+ const marker = createMarker({ state, parts });
const frame = () => {
for (const listener of [...preRender]) listener();
};
@@ -76,3 +77,113 @@ test('destroying the marker stops the horizon cull', () => {
marker.destroy();
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);
+});
diff --git a/src/layers/streetLevel/params.test.mjs b/src/layers/streetLevel/params.test.mjs
index 8a4ba4991..19a89a1ca 100644
--- a/src/layers/streetLevel/params.test.mjs
+++ b/src/layers/streetLevel/params.test.mjs
@@ -38,3 +38,21 @@ test('decodeParams round-trips and ignores unknown or malformed keys', () => {
assert.equal(empty.providers.size, 0);
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]],
+ );
+});
diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs
index a7d5ae898..8353791e3 100644
--- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs
+++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs
@@ -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)`);
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();
+});
diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js
index 034c76ab5..a374140e7 100644
--- a/src/layers/streetLevel/selection.js
+++ b/src/layers/streetLevel/selection.js
@@ -36,8 +36,11 @@ export function createSelection({ state, parts }) {
*/
function onKeyDown(event) {
if (event.key !== 'Escape' || event.defaultPrevented) return;
+ // A text field keeps its Esc, rich-text editors (contenteditable) included.
+ const target = event.target;
if (
- event.target?.closest?.(
+ target?.isContentEditable === true ||
+ target?.closest?.(
'.panel-collapsible, [role="dialog"], input, textarea, select',
)
)
diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs
index be9d739ce..198b421bc 100644
--- a/src/layers/streetLevel/selection.test.mjs
+++ b/src/layers/streetLevel/selection.test.mjs
@@ -8,7 +8,7 @@ import { createSelection } from './selection.js';
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
* has a known timeline.
*/
-function harness() {
+function harness({ selected = false } = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
@@ -17,6 +17,23 @@ function harness() {
const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task);
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 = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null };
@@ -48,8 +65,11 @@ function harness() {
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
- hasSelectedSequence: () => false,
- clearSequences() {},
+ hasSelectedSequence: () => sequences.selected,
+ clearSequences() {
+ sequences.cleared++;
+ sequences.selected = false;
+ },
},
});
selection.install(viewer);
@@ -58,6 +78,20 @@ function harness() {
);
return {
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,
canvas,
camera,
@@ -168,3 +202,58 @@ test('a hover frame queued before the layer went off does not pick', () => {
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
(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();
+ }
+});
diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs
index 3231f956b..c3cd01692 100644
--- a/src/layers/streetLevel/tileMath.test.mjs
+++ b/src/layers/streetLevel/tileMath.test.mjs
@@ -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));
});
+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)', () => {
// 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.
diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs
index 9c1971cfe..ecd3d16a4 100644
--- a/src/layers/streetLevel/view.test.mjs
+++ b/src/layers/streetLevel/view.test.mjs
@@ -3,6 +3,7 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraHeightAboveGround,
+ createHorizonCull,
metresBetween,
viewCentre,
viewFocus,
@@ -167,3 +168,100 @@ test('metresBetween measures the short way round the date line (review IC8 P2)',
) < 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();
+});
diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs
index 371e94ae6..8ff71200d 100644
--- a/src/layers/streetLevel/viewerHost.test.mjs
+++ b/src/layers/streetLevel/viewerHost.test.mjs
@@ -30,6 +30,11 @@ registerHooks({
const { createMapillaryViewer } =
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) {
const state = {
enabled: true,
@@ -38,17 +43,44 @@ function harness(adapter) {
street: { host: {}, renderMode: 'letterbox', follow: false },
};
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 marker = [];
+ const followed = { count: 0 };
const parts = {
- marker: { set() {}, clear() {} },
+ marker: {
+ set: (position, bearing) =>
+ marker.push(position && { position, bearing }),
+ clear: () => marker.push(null),
+ },
follow: {
- followCamera() {},
+ followCamera: () => followed.count++,
lookAtPosition: () => framing.started++,
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
* 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 };
let emit = null;
let mounted = false;
@@ -70,7 +102,7 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
mounted = true;
},
/** A pose event from the library, as MapillaryJS fires them. */
- emitPose(id) {
+ emitPose(id, overrides = {}) {
emit?.({
providerId: 'mapillary',
imageId: id,
@@ -79,12 +111,15 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
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() {
@@ -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');
});
-/** 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() {
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 {
constructor() {
viewers.created++;
@@ -231,21 +371,27 @@ function fakeLibrary() {
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() {}
+ 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) {
- return { id, cameraType: 'perspective', sequenceId: 's' };
+ viewers.moves.push(id);
+ await viewers.moveGates.get(id)?.promise;
+ return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
}
remove() {
if (this.removed) return;
@@ -337,3 +483,67 @@ test('closing the photo or switching the layer off stops its framing flight (rev
host.unmount();
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'],
+ );
+});
diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs
index 816dee764..a1d36bdcf 100644
--- a/src/tooling/mapillaryProvider.test.mjs
+++ b/src/tooling/mapillaryProvider.test.mjs
@@ -13,7 +13,9 @@ import * as tiles from '../../server/providers/mapillary/tiles.js';
import {
TILE_DISK_FILE_OVERHEAD_BYTES,
TILE_MAX_BYTES,
+ TILE_MEMORY_BUDGET_BYTES,
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
+ TILE_RATE_LIMIT_MAX_HOLD_MS,
TILE_ROUTE_MAX_PER_MIN,
TILE_UPSTREAM_CONCURRENCY,
} 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', () => {
assert.equal(
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) ──
/** 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 () => {
await withCacheDir(async (dir) => {
const files = [3, 2, 1].map((age) => ({
diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs
index bbce18442..dd549acd2 100644
--- a/src/ui/streetLevelControls.test.mjs
+++ b/src/ui/streetLevelControls.test.mjs
@@ -19,6 +19,22 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
*/
const mutations = { count: 0 };
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 {
constructor(document, tag, { id = null, dataset = {}, classes = [] } = {}) {
@@ -42,18 +58,30 @@ class FakeNode {
set: (value) => {
mutations.count++;
this.props[key] = value;
+ if (REFLECTED_RECORDS[key])
+ recordMutation(this, REFLECTED_RECORDS[key]);
},
});
this.attributes = new Map();
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 = {
- add: (name) => names.add(name),
- remove: (name) => names.delete(name),
+ add: (name) => {
+ names.add(name);
+ classChanged();
+ },
+ remove: (name) => {
+ names.delete(name);
+ classChanged();
+ },
contains: (name) => names.has(name),
toggle: (name, force) => {
const on = force ?? !names.has(name);
if (on) names.add(name);
else names.delete(name);
+ classChanged();
return on;
},
};
@@ -73,32 +101,59 @@ class FakeNode {
names.clear();
for (const name of String(value).split(/\s+/).filter(Boolean))
names.add(name);
+ classChanged();
},
});
}
get childElementCount() {
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) {
mutations.count++;
this.attributes.set(key, String(value));
+ recordMutation(this, { type: 'attributes', attributeName: key });
}
getAttribute(key) {
return this.attributes.get(key) ?? null;
}
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) {
return this.insertBefore(child, null);
}
+ /** Like the DOM's: a reference that is not a child throws NotFoundError. */
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();
const index = reference
? this.children.indexOf(reference)
: this.children.length;
- this.children.splice(index < 0 ? this.children.length : index, 0, child);
+ this.children.splice(index, 0, child);
child.parent = this;
+ recordMutation(this, { type: 'childList' });
return child;
}
append(...nodes) {
@@ -109,9 +164,11 @@ class FakeNode {
this.append(...nodes);
}
remove() {
- if (!this.parent) return;
- this.parent.children.splice(this.parent.children.indexOf(this), 1);
+ const parent = this.parent;
+ if (!parent) return;
+ parent.children.splice(parent.children.indexOf(this), 1);
this.parent = null;
+ recordMutation(parent, { type: 'childList' });
}
contains(node) {
for (let current = node; current; current = current.parent)
@@ -272,7 +329,7 @@ function panelDom() {
globe.setAttribute('tabindex', '0');
// A field in another panel (the 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. */
@@ -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', () =>
withDom(async (dom) => {
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);
dom.globe.focus();
keydown(dom.globe, 'Tab');
@@ -614,7 +676,13 @@ test('Tab from outside the expanded viewer brings focus back into it', () =>
assert.equal(
dom.document.activeElement,
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();
}));
@@ -742,7 +810,12 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
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() {
const observers = [];
class FakeMutationObserver {
@@ -750,19 +823,46 @@ function fakeMutationObserver() {
this.callback = callback;
this.target = null;
this.options = null;
+ this.records = [];
observers.push(this);
}
- observe(target, options) {
+ observe(target, options = {}) {
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() {
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 = () => {
- for (const observer of observers)
- if (observer.target) observer.callback([], observer);
+ for (const observer of observers) observer.deliver();
};
const watching = () => observers.some((observer) => observer.target);
return { FakeMutationObserver, fire, watching };
@@ -776,6 +876,7 @@ async function withObserver(fn) {
try {
return await fn(fake);
} finally {
+ liveObservers.clear();
if (saved === undefined) delete globalThis.MutationObserver;
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 /);
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 ',
+ );
+ }));
+
+test('a 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);
+ }),
+ ));
diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs
index 3a5bc1748..9fe69d79b 100644
--- a/src/ui/streetLevelPresentation.test.mjs
+++ b/src/ui/streetLevelPresentation.test.mjs
@@ -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}`);
});
+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', () => {
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
text: 'OFF',