From 3af0f80cdbd4ff2492e397e525f9b0983b591ae9 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 6 Oct 2026 16:54:06 +0100 Subject: [PATCH] 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 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 --- src/data/layerState.js | 2 + src/data/layerState.test.mjs | 62 +++++ src/layers/streetLevel/filter.test.mjs | 18 ++ src/layers/streetLevel/index.test.mjs | 63 +++++ src/layers/streetLevel/marker.test.mjs | 117 ++++++++- src/layers/streetLevel/params.test.mjs | 18 ++ .../providers/mapillary/coverage.test.mjs | 54 ++++ src/layers/streetLevel/selection.js | 5 +- src/layers/streetLevel/selection.test.mjs | 95 +++++++- src/layers/streetLevel/tileMath.test.mjs | 43 ++++ src/layers/streetLevel/view.test.mjs | 98 ++++++++ src/layers/streetLevel/viewerHost.test.mjs | 230 +++++++++++++++++- src/tooling/mapillaryProvider.test.mjs | 200 +++++++++++++++ src/ui/streetLevelControls.test.mjs | 206 +++++++++++++++- src/ui/streetLevelPresentation.test.mjs | 17 ++ 15 files changed, 1198 insertions(+), 30 deletions(-) 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',