From 3d4c6de21a72eb54bf22adea46c11022e90e2da3 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 13 Oct 2026 20:43:27 +0100 Subject: [PATCH] =?UTF-8?q?test(street-level):=20harden=20KartaView=20test?= =?UTF-8?q?s=20(mutation=20score=2051%=20=E2=86=92=2095%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation review (91, then 130 mutants) found tests that could not fail (quick presses with synchronous fakes, a no-flight check with a provider that never posed), and untested paths. New and rewritten tests kill 123 of 130 mutants; the 7 survivors are equivalent or harmless redraws. - viewer: held-back loads for quick presses, stale sharp/flat loads, close mid-load, ends checks for an older photo, WebGL retried per mount, focus handed back, look direction kept on ›. - provider: id-ordered pages with out-of-order indexes, page and probe failures retried, overtaken lookups, cones re-centred on ‹ ›, leftover imagery filters hide no line or cone, coverage hints, tile cache. - source: 429 backoff and Retry-After under mock timers, hang timeouts, the nearest query's parameters. policy: storage-host injection, FOV 360, longitudes past ±180. panorama: a stub WebGL context (angles, lost context, re-measuring). lineCoverage.test.mjs: tooHigh hint, passes(). - core: openImage({frame:false}) and openNearest leave the camera (with a posing provider and a default-open control), KartaView's legend and wording end to end; viewerHost supportsRenderModes. - Fixed tick-count waits replaced by an until() poll. - Browser gate fixture: odd photos archived (409 at storage2, served by the proxy), the first lookup rate-limited (429, Retry-After: 0); the line-click step asserts the camera stays and › skips the archived photo. Co-Authored-By: Claude Opus 5.5 --- scripts/fixtures/street-level/kartaView.mjs | 41 +- scripts/format-scope.json | 1 + scripts/qa-street-level.mjs | 46 ++- src/layers/streetLevel/index.test.mjs | 63 ++- src/layers/streetLevel/lineCoverage.test.mjs | 142 +++++++ .../providers/kartaview/panorama.test.mjs | 105 +++++ .../providers/kartaview/policy.test.mjs | 68 +++- .../providers/kartaview/provider.test.mjs | 365 ++++++++++++++++-- .../providers/kartaview/source.test.mjs | 93 +++++ .../providers/kartaview/viewer.test.mjs | 354 +++++++++++++++-- src/layers/streetLevel/viewerHost.test.mjs | 14 + src/testSupport/kartaViewFakes.mjs | 13 + src/testSupport/streetLevelFakes.mjs | 94 +++++ src/tooling/streetLevelQa.test.mjs | 56 ++- 14 files changed, 1374 insertions(+), 81 deletions(-) create mode 100644 src/layers/streetLevel/lineCoverage.test.mjs diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index ccc7564e2..559c6a465 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -6,7 +6,11 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; * the gate's KartaView steps need no network. One drive runs north along * KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while * their `fileurl*` name storage1, as KartaView's API does, so the gate also - * proves the provider builds addresses from `storage`. + * proves the provider builds addresses from `storage`. Every odd-indexed + * photo is archived (storage answers 409, the image proxy still serves it), + * so the nearest photo opened is even and › must skip the one after it. The + * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as + * KartaView does under load. */ export const KV_SEQUENCE_ID = '900001'; @@ -21,7 +25,15 @@ const SHOT = '2024-05-02 16:20:00.000'; const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); -/** The drive's photos, south to north; every tenth is 360°. */ +/** Lookups answered so far: the first one is rate-limited. */ +let nearestLookups = 0; + +/** Forget the lookups answered, so the next one is rate-limited again. */ +export function resetKartaViewFixture() { + nearestLookups = 0; +} + +/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */ export function kartaViewPhotos() { const step = KV_SPACING_M / METRES_PER_DEG_LAT; const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1; @@ -31,6 +43,7 @@ export function kartaViewPhotos() { lon: KV_LINE.lon, lat: Number((KV_LINE.south + i * step).toFixed(7)), isPano: i % 10 === 5, + archived: i % 2 === 1, })); } @@ -127,10 +140,16 @@ export function answerKartaViewRequest({ method, url }) { return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname) ? image : { ...raw(404, {}), known: false }; - if (address.hostname === 'storage2.openstreetcam.org') - return address.pathname.includes(KV_SEQUENCE_ID) - ? image - : { ...raw(404, {}), known: false }; + if (address.hostname === 'storage2.openstreetcam.org') { + if (!address.pathname.includes(KV_SEQUENCE_ID)) + return { ...raw(404, {}), known: false }; + const archived = photos.some( + (photo) => + photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`), + ); + // An archived photo's file is gone from storage: KartaView answers 409. + return archived ? { ...raw(409, {}), known: true } : image; + } if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname)) // The wrong host the API names: KartaView answers 404 there. return { ...raw(404, {}), known: false }; @@ -148,6 +167,16 @@ export function answerKartaViewRequest({ method, url }) { known: true, }; if (path === '/2.0/photo/') { + if (nearestLookups++ === 0) + return { + ...raw(429, {}), + headers: { + ...CORS, + 'Retry-After': '0', + 'Access-Control-Expose-Headers': 'Retry-After', + }, + known: true, + }; const q = address.searchParams; const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; const near = photos diff --git a/scripts/format-scope.json b/scripts/format-scope.json index dbfa055ea..a77fc11f9 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -921,6 +921,7 @@ "src/layers/streetLevel/providers/kartaview/panorama.test.mjs", "src/layers/streetLevel/providers/kartaview/viewer.test.mjs", "src/layers/streetLevel/providers/kartaview/provider.test.mjs", + "src/layers/streetLevel/lineCoverage.test.mjs", "src/testSupport/kartaViewFakes.mjs", "src/testSupport/googleStreetViewFakes.mjs", "server/providers/mapillary.js", diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 60cb7f379..7c8a92b30 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -850,7 +850,7 @@ async function main() { }, ); await step( - 'KartaView: a click on a line opens the nearest photo, cones follow its drive, and › steps along it', + 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { const along = Array.from({ length: 9 }, (_, i) => [ KV_LINE.lon, @@ -871,6 +871,17 @@ async function main() { if (!handle && !fixtures) return skip('no KartaView line in view'); assert.ok(handle, 'a KartaView line is pickable'); const at = await handle.jsonValue(); + const camera = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + height: c.height, + }; + }); + const parked = await camera(); + // The first lookup is rate-limited (fixtures): it waits and retries. await page.mouse.click(at.x, at.y); await page.waitForFunction( () => { @@ -894,10 +905,22 @@ async function main() { }), 'the photo is on screen', ); + // A line click picks a spot: the camera stays where it was. + await sleep(1500); + const now = await camera(); + assert.ok( + Math.abs(now.lon - parked.lon) < 1e-6 && + Math.abs(now.lat - parked.lat) < 1e-6 && + Math.abs(now.height - parked.height) < 0.5, + `the camera did not move: ${JSON.stringify({ parked, now })}`, + ); if (fixtures) { - assert.ok( - photos.some((photo) => photo.id === street.imageId), - `${street.imageId} is a fixture photo`, + const opened = photos.find((photo) => photo.id === street.imageId); + assert.ok(opened, `${street.imageId} is a fixture photo`); + assert.equal( + opened.archived, + false, + 'the nearest photo that storage still has', ); assert.ok( await uiUntil( @@ -922,12 +945,21 @@ async function main() { ), '› opened the next photo', ); - if (fixtures) + if (fixtures) { + const skipped = photos.find( + (photo) => photo.id === String(Number(before) + 1), + ); + assert.equal( + skipped?.archived, + true, + 'the photo after is archived', + ); assert.equal( (await ui()).street.imageId, - String(Number(before) + 1), - 'the next photo along the drive', + String(Number(before) + 2), + '› skipped the archived photo to the next one along the drive', ); + } }, ); if (fixtures) diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index ccc8b9dfe..adbeaf0bc 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -4,6 +4,7 @@ import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { createMapillaryProvider } from './providers/mapillary/index.js'; +import { createKartaViewProvider } from './providers/kartaview/index.js'; import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs'; /** @@ -92,8 +93,8 @@ function deferred() { } /** A provider whose viewer reports a pose for every image it opens. */ -function posingProvider() { - const provider = fakeProvider(); +function posingProvider(options) { + const provider = fakeProvider(options); const create = provider.create; provider.create = (context) => { const instance = create(context); @@ -838,3 +839,61 @@ test('a failed lookup’s error goes when another source is lit; a provider can assert.deepEqual(other.calls.open, ['img-1']); assert.equal(viewer.flights.started, 0); }); + +test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => { + const provider = posingProvider(); + const { layer, viewer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + await provider.context().actions.openImage('img-1', { frame: false }); + assert.deepEqual(provider.calls.open, ['img-1']); + assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera'); + await provider.context().actions.openImage('img-2'); + assert.equal(viewer.flights.started, 1, 'a default open frames the image'); +}); + +test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => { + const lit = posingProvider({ nearestImage: async () => 'lit-near' }); + const other = fakeProvider({ + id: 'other', + pickPrefix: 'oth:', + nearestImage: async () => 'other-near', + }); + const { layer, viewer } = await enabledLayer(t, [lit, other]); + layer.attachViewerHost({}); + const point = { lat: 38.58, lon: -121.49 }; + // The other source is off: its click asks nobody else. + await other.context().actions.openNearest(point); + await settle(); + assert.deepEqual(lit.calls.open, [], 'the lit source was not asked'); + assert.deepEqual(other.calls.open, []); + await lit.context().actions.openNearest(point); + assert.deepEqual(lit.calls.open, ['lit-near']); + assert.equal(viewer.flights.started, 0, 'the user picked the spot'); +}); + +test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => { + const kartaView = createKartaViewProvider({ + source: { + getTile: async () => ({ lines: [] }), + nearestPhotos: async () => [], + getPhoto: async () => { + throw new Error('KartaView has no such photo'); + }, + getSequencePhotos: async () => ({ photos: [], hasMore: false }), + probeImage: async () => true, + }, + }); + const { layer } = await enabledLayer(t, [kartaView]); + layer.setProviderEnabled('kartaview', true); + await settle(); + const ui = layer.getUIState(); + assert.deepEqual( + ui.legend.map((entry) => entry.key), + ['kartaview'], + ); + assert.equal(ui.coverage.label?.noun, 'streets'); + assert.equal( + ui.providers.find((p) => p.id === 'kartaview').highlightsSequence, + false, + ); +}); diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs new file mode 100644 index 000000000..88308673c --- /dev/null +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createLineCoverage } from './lineCoverage.js'; +import { + coverageViewer, + tileCentre, +} from '../../testSupport/streetLevelFakes.mjs'; +import { until } from '../../testSupport/kartaViewFakes.mjs'; + +const TUNING = Object.freeze({ + maxTiles: 9, + overviewMaxTiles: 0, + maxSequences: 900, + lineWidthPx: 2, + overviewPointPx: 2, + moveDebounceMs: 10, + viewRangeMinM: 2_500, + viewRangePerHeight: 10, + viewNearM: 1_000, +}); + +/** + * Line coverage over one tile holding one line, on a viewer looking down from + * `height`. `fetchTile` answers with that line unless `fail` says otherwise. + */ +function setup( + t, + { zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {}, +) { + const centre = tileCentre(-121.4944, 38.5816); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height: 900 }, + t, + ); + const state = { + viewer, + services: {}, + keyRequired: false, + keyRejected: false, + context: { + getFilter: () => ({ pano: 'all', sinceMs: null }), + isActive: () => true, + notify() {}, + getSurface: () => 'draped', + groundCaster: null, + meshSampler: null, + }, + coverage: { + zoom: null, + kind: null, + tiles: new Map(), + stale: new Map(), + staleTimer: null, + pending: new Map(), + lastError: null, + holdUntil: 0, + holdTimer: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: Promise.resolve(), + hint: '', + }, + sequence: { selectedId: null }, + }; + const asked = []; + const coverage = createLineCoverage({ + state, + fetchTile: async (tile) => { + asked.push(tile); + if (fail) throw fail; + return { + sequences: [ + { + id: 'line-1', + parts: [ + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], + ], + capturedAt: 0, + isPano: false, + }, + ], + overview: [], + }; + }, + zoomFor: + zoomFor ?? + ((height) => + Number.isFinite(height) && height <= 6_000 + ? { zoom: 14, kind: 'sequence' } + : null), + colors: { coverage: '#635bff', selected: '#00e5ff' }, + pickPrefix: 'test:line:', + renderKey: 'test-coverage', + tuning: TUNING, + messages, + ...(passes ? { passes } : {}), + }); + t.after(() => { + coverage.clear(); + coverage.resetErrors(); + }); + return { viewer, state, coverage, asked }; +} + +test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => { + const tooHigh = 'zoom in to see test coverage'; + const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } }); + high.viewer.view.height = 7_000; + high.coverage.refresh(); + assert.equal(high.state.coverage.hint, tooHigh); + assert.equal(high.state.coverage.zoom, null); + assert.equal(high.asked.length, 0, 'nothing is asked from up there'); + const generic = setup(t); + generic.viewer.view.height = 7_000; + generic.coverage.refresh(); + assert.notEqual(generic.state.coverage.hint, tooHigh); + assert.match(generic.state.coverage.hint, /Point the camera/); +}); + +test('a provider’s passes() decides which lines are drawn and counted', async (t) => { + const all = setup(t, { passes: () => true }); + all.coverage.refresh(); + assert.ok(await until(() => all.state.coverage.tiles.size === 1)); + assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn'); + const none = setup(t, { passes: () => false }); + none.coverage.refresh(); + assert.ok(await until(() => none.state.coverage.tiles.size === 1)); + assert.equal(none.coverage.sequenceCount(), 0, 'filtered out'); +}); + +test('a refused key without its own message keeps the error it came with', async (t) => { + const refused = Object.assign(new Error('the source refused the key'), { + keyRejected: true, + }); + const { state, coverage } = setup(t, { fail: refused }); + coverage.refresh(); + assert.ok(await until(() => state.keyRejected === true)); + assert.equal(state.coverage.lastError, 'the source refused the key'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs index eddf7de4b..8bb9c9d62 100644 --- a/src/layers/streetLevel/providers/kartaview/panorama.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/panorama.test.mjs @@ -6,6 +6,7 @@ import { PANO_PITCH_LIMIT_DEG, clampFov, clampPitch, + createPanoramaRenderer, rayToUv, wrapDegrees, } from './panorama.js'; @@ -35,3 +36,107 @@ test('view limits: pitch stops short of the poles, field of view stays usable', assert.equal(wrapDegrees(-30), 330); assert.equal(wrapDegrees(725), 5); }); + +/** + * A WebGL stand-in that records draw calls and uniform values; `lost` makes + * it report a lost context. + */ +function stubGl() { + const gl = { + lost: false, + calls: [], + uniforms: {}, + isContextLost: () => gl.lost, + getShaderParameter: () => true, + getProgramParameter: () => true, + getUniformLocation: (program, name) => name, + getExtension: () => null, + uniform1f: (name, value) => (gl.uniforms[name] = value), + }; + return new Proxy(gl, { + get(target, key) { + if (key in target) return target[key]; + if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key; + return (...args) => { + target.calls.push(key); + return { key, args }; + }; + }, + }); +} + +/** A panorama on a stub canvas, with frames run when the test says. */ +function stubPanorama(t, { width = 400, height = 250 } = {}) { + const gl = stubGl(); + const canvas = { + clientWidth: width, + clientHeight: height, + width: 300, + height: 150, + getContext: () => gl, + }; + const frames = []; + const saved = { + request: globalThis.requestAnimationFrame, + cancel: globalThis.cancelAnimationFrame, + }; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + globalThis.cancelAnimationFrame = () => {}; + t.after(() => { + globalThis.requestAnimationFrame = saved.request; + globalThis.cancelAnimationFrame = saved.cancel; + }); + const renderer = createPanoramaRenderer(canvas); + const runFrames = () => { + while (frames.length) frames.shift()(); + }; + return { gl, canvas, renderer, runFrames }; +} + +const IMAGE = { width: 1024, height: 512 }; + +test('a panorama draws once it has an image; looking up and right are positive angles', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.render(); + runFrames(); + assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet'); + renderer.setImage(IMAGE); + renderer.setView({ yaw: 30, pitch: 30 }); + runFrames(); + assert.ok(gl.calls.includes('drawArrays')); + assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`); + assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9); + assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9); + renderer.setView({ pitch: -20 }); + runFrames(); + assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative'); +}); + +test('a lost WebGL context draws nothing', (t) => { + const { gl, renderer, runFrames } = stubPanorama(t); + renderer.setImage(IMAGE); + gl.lost = true; + gl.calls.length = 0; + runFrames(); + assert.deepEqual( + gl.calls.filter((call) => + ['viewport', 'clear', 'drawArrays'].includes(call), + ), + [], + ); +}); + +test('the canvas is measured again when it draws, not only when its image arrived', (t) => { + // The image arrives while the canvas is still hidden (0×0)… + const { canvas, renderer, runFrames } = stubPanorama(t, { + width: 0, + height: 0, + }); + renderer.setImage(IMAGE); + assert.deepEqual([canvas.width, canvas.height], [1, 1]); + // …and is laid out before the frame draws. + canvas.clientWidth = 400; + canvas.clientHeight = 250; + runFrames(); + assert.deepEqual([canvas.width, canvas.height], [400, 250]); +}); diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 8052a0d20..c9ab2ca4b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -72,13 +72,17 @@ test('photo files come from the storage host the record names, not the one its U for (const fileurlLTh of [ 'https://tracker.example/x.jpg', 'http://storage1.openstreetcam.org/a.jpg', + 'https://storage1.openstreetcam.org.evil.example/a.jpg', 'javascript:alert(1)', ]) { const hostile = normalizePhoto( record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }), ); assert.notEqual(hostile.urls.view, fileurlLTh); - assert.doesNotMatch(String(hostile.urls.view), /tracker|javascript|^http:/); + assert.doesNotMatch( + String(hostile.urls.view), + /tracker|javascript|evil|^http:/, + ); } // An unexpected storage value falls back to the URLs as given. const odd = normalizePhoto(record({ storage: 'https://evil.example' })); @@ -171,3 +175,65 @@ test('resized images go through the proxy with a base64url address', () => { ); assert.equal(resizedImageUrl(null, [1, 1]), null); }); + +test('a storage value that only contains a storage host name builds no address from it', () => { + for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) { + const photo = normalizePhoto(record({ storage })); + // Not a storage host: the API's own storage URLs serve. + assert.equal( + photo.urls.view, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + assert.equal( + photo.urls.full, + 'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + storage, + ); + } +}); + +test('a 360° field of view makes a panorama whatever the projection says', () => { + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano, + true, + ); + assert.equal( + normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano, + false, + ); +}); + +test('a longitude past ±180 is not a position', () => { + assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null); + assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null); + assert.equal( + normalizePhoto(record({ lng: '180', matchLng: '180' })).lon, + 180, + ); +}); + +test('with no processed-photo path the size prefix gives the full photo', () => { + const photo = normalizePhoto( + record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }), + ); + assert.equal( + photo.urls.full, + 'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg', + ); + // Only the large thumbnail, no path at all: the thumbnail still shows. + const thumbOnly = normalizePhoto( + record({ + filepathProc: '', + fileurlProc: '', + filepath: '', + fileurl: '', + storage: 'storage3', + }), + ); + assert.equal(thumbOnly.urls.full, null); + assert.match( + thumbOnly.urls.view, + /^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 000d4458b..9c63e9c18 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -5,9 +5,17 @@ import { createTileCache, kartaViewZoomFor, } from './index.js'; -import { SEQUENCE_PAGE_SIZE } from './policy.js'; +import { + NO_COVERAGE_HINT, + SEQUENCE_PAGE_SIZE, + TOO_HIGH_HINT, +} from './policy.js'; import { validateProviders } from '../../registry.js'; -import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; +import { + coverageViewer, + tileCentre, +} from '../../../../testSupport/streetLevelFakes.mjs'; /** A photo record `metres` east of (42.3314, -83.0458), in sequence `seq` at `index`. */ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { @@ -71,13 +79,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { }; } -function fakeContext() { +function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { const actions = { opened: [], nearest: [], instance: null }; return { actions, context: { services: {}, - getFilter: () => ({ pano: 'all', sinceMs: null }), + getFilter: () => filter, isActive: () => true, groundCaster: null, meshSampler: null, @@ -97,7 +105,7 @@ function fakeContext() { }; } -function setup(sourceOptions) { +function setup(sourceOptions, contextOptions) { const source = fakeSource(sourceOptions); const loads = []; const definition = createKartaViewProvider({ @@ -112,7 +120,7 @@ function setup(sourceOptions) { }, }, }); - const { context, actions } = fakeContext(); + const { context, actions } = fakeContext(contextOptions); const instance = definition.create(context); actions.instance = instance; return { definition, instance, source, actions, loads }; @@ -126,6 +134,11 @@ test('KartaView registers keyless, off by default, with no filters and its own w assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); assert.deepEqual(definition.filters, []); + assert.equal( + definition.highlightsSequence, + false, + 'its lines are streets: a selected drive shows as cones', + ); assert.match( definition.credit.html, /KartaView<\/a> contributors, CC BY-SA 4\.0/, @@ -147,6 +160,11 @@ test('coverage is drawn from z14 near the street and z13 a little higher; never assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' }); assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' }); assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' }); + assert.deepEqual( + kartaViewZoomFor(6_000), + { zoom: 13, kind: 'sequence' }, + 'the top of the range still draws', + ); assert.equal(kartaViewZoomFor(6_001), null); assert.equal(kartaViewZoomFor(NaN), null); }); @@ -206,13 +224,16 @@ test('coverage stats report KartaView as configured and key-free', async () => { assert.equal(stats.count, 0); }); -const press = async (button) => { +/** Press a step button and wait until it has opened a photo (`opened` grew). */ +const press = async (button, opened) => { + const before = opened.length; button.fire('click'); - for (let i = 0; i < 12; i++) await settle(); + await until(() => opened.length > before); + await settle(); }; -async function mounted(sourceOptions) { - const env = setup(sourceOptions); +async function mounted(sourceOptions, contextOptions) { + const env = setup(sourceOptions, contextOptions); const host = fakeHost(); await env.instance.viewer.mount(host); const poses = []; @@ -228,20 +249,26 @@ test('› walks across sequence pages without moving the camera; ‹ walks back; sequences: { s: indexes }, }); await instance.viewer.open(`s-${size - 1}`); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), `s-${size}`, 'onto the second page'); assert.deepEqual(actions.opened.at(-1), { id: `s-${size}`, options: { frame: false }, }); assert.ok(source.calls.pages.includes('s:2')); - await press(prev); + await press(prev, poses); assert.equal(poses.at(-1), `s-${size - 1}`); await instance.viewer.open('s-0'); - for (let i = 0; i < 6; i++) await settle(); - assert.equal(prev.disabled, true, 'nothing before the first photo'); - await press(prev); - assert.equal(poses.at(-1), 's-0'); + assert.ok(await until(() => prev.disabled), 'nothing before the first photo'); + // ‹ at the start opens nothing: presses run in turn, so once a later › + // has opened its photo, a ‹ that opened one would show before it. + const before = actions.opened.length; + prev.fire('click'); + await press(next, poses); + assert.deepEqual( + actions.opened.slice(before).map((open) => open.id), + ['s-1'], + ); }); test('pages that overlap in index (id order) step one photo at a time across the edge', async () => { @@ -260,7 +287,7 @@ test('pages that overlap in index (id order) step one photo at a time across the sequences: { s: [...first, ...second] }, }); await instance.viewer.open(`s-${size - 5}`); - for (let k = 1; k <= 8; k++) await press(next); + for (let k = 1; k <= 8; k++) await press(next, poses); assert.deepEqual( poses.slice(-9).map((id) => Number(id.slice(2))), Array.from({ length: 9 }, (_, i) => size - 5 + i), @@ -278,14 +305,13 @@ test('a photo whose index outruns the page count is found, and its neighbours ar sequences: { s: indexes }, }); await instance.viewer.open('s-1500'); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-1501'); - await press(prev); - await press(prev); + await press(prev, poses); + await press(prev, poses); assert.equal(poses.at(-1), 's-499', 'back across the gap'); await instance.viewer.open('s-1999'); - for (let i = 0; i < 8; i++) await settle(); - assert.equal(next.disabled, true, 'the last photo'); + assert.ok(await until(() => next.disabled), 'the last photo'); }); test('archived photos are skipped: by the nearest lookup, and by › and ‹', async () => { @@ -302,7 +328,7 @@ test('archived photos are skipped: by the nearest lookup, and by › and ‹', a 'kept', ); await instance.viewer.open('s-1'); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-4', 'two archived photos stepped over'); const allGone = setup({ nearest: [photoRecord('gone', { metres: 3 })], @@ -348,12 +374,14 @@ async function withCones(t, env) { }, camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, }); - return () => { + const cones = () => { const indexes = []; for (let i = 0; i < collection.length; i++) indexes.push(Number(collection.get(i).id.split('-').at(-1))); return [Math.min(...indexes), Math.max(...indexes)]; }; + cones.count = () => collection?.length ?? 0; + return cones; } test('the cones follow the open photo to another page of a long sequence, cached or not', async (t) => { @@ -362,26 +390,27 @@ test('the cones follow the open photo to another page of a long sequence, cached sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, }); const cones = await withCones(t, env); - const settleLong = async () => { - for (let i = 0; i < 40; i++) await settle(); - }; + const drawnAround = (index) => + until( + () => + !env.instance.sequenceStats().loading && + cones.count() > 0 && + cones()[0] <= index && + cones()[1] >= index, + ); await env.instance.viewer.open('s-10'); await env.instance.selectSequence('s'); - await settleLong(); + assert.ok(await drawnAround(10)); assert.ok(cones()[1] < size, `first page: ${cones()}`); // A line click further along the same drive: the core does not reselect. await env.instance.viewer.open(`s-${size * 4 + 10}`); - await settleLong(); + assert.ok(await drawnAround(size * 4 + 10), `re-centred: ${cones()}`); assert.ok(cones()[0] >= size * 3, `re-centred: ${cones()}`); // Closed and reopened on another page: not the sequence's cached cones. env.instance.clearSequence(); await env.instance.viewer.open(`s-${size * 2 + 10}`); await env.instance.selectSequence('s'); - await settleLong(); - assert.ok( - cones()[0] <= size * 2 + 10 && cones()[1] >= size * 2 + 10, - `around the photo: ${cones()}`, - ); + assert.ok(await drawnAround(size * 2 + 10), `around the photo: ${cones()}`); }); test('a storage hiccup is not remembered as an archived photo', async () => { @@ -406,14 +435,18 @@ test('a photo far past a run of deleted photos still finds its page and neighbou ...Array.from({ length: 1000 }, (_, i) => i), ...Array.from({ length: 20 }, (_, i) => 6000 + i), ]; - const { instance, poses, next, prev } = await mounted({ + const { instance, poses, next, prev, source } = await mounted({ sequences: { s: indexes }, }); await instance.viewer.open('s-6005'); - for (let i = 0; i < 12; i++) await settle(); + // Both ends checks have found a neighbour (and probed it) before the look. + const probed = (id) => + source.calls.probes.some((url) => url.endsWith(`/${id}.jpg`)); + assert.ok(await until(() => probed('s-6004') && probed('s-6006'))); + await settle(); assert.equal(next.disabled, false); assert.equal(prev.disabled, false); - await press(next); + await press(next, poses); assert.equal(poses.at(-1), 's-6006'); }); @@ -445,6 +478,10 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing', now = 2000; await tiles.get(a, {}); assert.equal(asked.length, 5, 'stale after the TTL'); + // The same x/y at another zoom is another tile. + await tiles.get({ ...a, z: 13 }, {}); + assert.deepEqual(asked.at(-1), '13/1/2'); + assert.equal(asked.length, 6); // A failed load is not kept. const failing = createTileCache({ load: async () => { @@ -455,3 +492,257 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing', }); await assert.rejects(failing.get(a, {}), /did not answer/); }); + +test('a page in id order, its indexes out of order, still steps in index order', async () => { + const { instance, poses, next, prev } = await mounted({ + sequences: { s: [0, 2, 1, 3, 4] }, + }); + await instance.viewer.open('s-0'); + await press(next, poses); + assert.equal(poses.at(-1), 's-1'); + await press(next, poses); + assert.equal(poses.at(-1), 's-2'); + await press(prev, poses); + assert.equal(poses.at(-1), 's-1'); +}); + +test('a sequence page that failed once is asked again: › then reaches the next photo', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3] } }); + const real = env.source.getSequencePhotos; + let fail = true; + env.source.getSequencePhotos = async (...args) => { + env.source.calls.pages.push('failing'); + if (fail) throw new Error('KartaView did not answer in time — try again'); + return real(...args); + }; + await env.instance.viewer.open('s-1'); + // The ends checks ask for the page and get no answer. + assert.ok(await until(() => env.source.calls.pages.includes('failing'))); + await settle(); + fail = false; + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-2'); +}); + +test('a lookup overtaken while it probes shares the probe with the next one, which finds the photo', async () => { + const env = setup({ nearest: [photoRecord('kept', { metres: 9 })] }); + let answer; + env.source.probeImage = (url) => { + env.source.calls.probes.push(url); + return new Promise((resolve) => (answer = resolve)); + }; + env.source.nearestPhotos = async (point, { signal } = {}) => { + signal?.throwIfAborted(); + return [photoRecord('kept', { metres: 9 })]; + }; + const point = { lat: 42.3314, lon: -83.0458 }; + const controller = new AbortController(); + const first = env.instance.nearestImage(point, { signal: controller.signal }); + assert.ok(await until(() => env.source.calls.probes.length === 1)); + controller.abort(new Error('overtaken')); + const second = env.instance.nearestImage(point); + await settle(); + answer(true); + assert.equal(await second, 'kept'); + await first.catch(() => {}); + assert.equal(env.source.calls.probes.length, 1, 'one probe, shared'); + // A definite answer is kept: the next lookup asks storage nothing. + assert.equal(await env.instance.nearestImage(point), 'kept'); + assert.equal(env.source.calls.probes.length, 1); + // A lookup aborted before KartaView answers gives up. + const late = new AbortController(); + late.abort(new Error('closed')); + await assert.rejects( + env.instance.nearestImage(point, { signal: late.signal }), + /closed/, + ); +}); + +test('a probe that fails outright is tried, and asked again next time', async () => { + const env = setup({ nearest: [photoRecord('near', { metres: 3 })] }); + let probe = async () => { + throw new TypeError('Failed to fetch'); + }; + env.source.probeImage = (url) => probe(url); + const point = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await env.instance.nearestImage(point), 'near'); + probe = async () => false; + await assert.rejects(env.instance.nearestImage(point), /archived/); +}); + +test('a photo past the last page asks for no page beyond it', async () => { + const env = await mounted({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + // KartaView knows photo 12, but no page lists it (moved or re-indexed). + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-12' + ? photoRecord('s-12', { seq: 's', index: 12, metres: 60 }) + : getPhoto(id); + await env.instance.viewer.open('s-12'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-9'); + assert.deepEqual([...new Set(env.source.calls.pages)], ['s:1']); +}); + +test('the cones re-centre when ‹ › walk off the page they were drawn from', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 3 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const settled = () => + until(() => !env.instance.sequenceStats().loading && cones.count() > 0); + // Near the start of page 2: the cones show page 2 alone. + await env.instance.viewer.open(`s-${size + 12}`); + await env.instance.selectSequence('s'); + assert.ok(await settled()); + assert.deepEqual(cones(), [size, size * 2 - 1]); + // ‹ back across the edge onto page 1. + for (let k = 0; k < 13; k++) await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), `s-${size - 1}`); + assert.ok( + await until(() => cones()[0] < size), + `drawn around page 1 now: ${cones()}`, + ); + // › forward, off the far end of page 2. + await env.instance.viewer.open(`s-${size * 2 - 13}`); + assert.ok(await until(() => cones()[0] >= size), `${cones()}`); + for (let k = 0; k < 13; k++) await press(env.next, env.poses); + assert.equal(env.poses.at(-1), `s-${size * 2}`); + assert.ok( + await until(() => cones()[1] >= size * 2), + `drawn around page 3 now: ${cones()}`, + ); +}); + +test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => { + const env = await mounted( + { sequences: { s: [0, 1, 2, 3] } }, + { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, + ); + // Undated records: no capture time to pass a "since" filter with. + const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => undated(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(undated) }; + }; + const cones = await withCones(t, env); + await env.instance.viewer.open('s-1'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => !env.instance.sequenceStats().loading)); + assert.equal(env.instance.sequenceStats().images, 4); + assert.equal(cones.count(), 4, 'every cone drawn'); +}); + +/** + * The provider drawing coverage on a stand-in viewer above one z14 tile. + * `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held. + */ +function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { + const env = setup({}, { filter }); + const centre = tileCentre(-83.0458, 42.3314); + const viewer = coverageViewer( + { lon: centre.lon, lat: centre.lat, height }, + t, + ); + const tiles = []; + let hold = null; + env.source.getTile = (tile) => { + tiles.push(`${tile.z}/${tile.x}/${tile.y}`); + const answer = { + lines: lines.map((track) => ({ + track: track.map(([lon, lat]) => ({ + lat: String(lat), + lng: String(lon), + })), + })), + }; + return hold ? hold.then(() => answer) : Promise.resolve(answer); + }; + env.instance.init(viewer); + t.after(() => env.instance.destroy(viewer)); + return { + ...env, + viewer, + centre, + tiles, + holdTiles() { + let release; + hold = new Promise((resolve) => (release = resolve)); + return () => { + hold = null; + release(); + }; + }, + }; +} + +test('a leftover imagery filter hides no KartaView line', async (t) => { + const { instance, viewer, centre } = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 }, + lines: [ + [ + [-83.0458 - 0.001, 42.3314], + [-83.0458 + 0.001, 42.3314], + ], + ], + }); + assert.ok(centre); + instance.activate(viewer); + assert.ok(await until(() => instance.coverageStats().count === 1)); + assert.equal(instance.coverageStats().hint, ''); +}); + +test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { + const env = coverageSetup(t, { height: 7_000 }); + env.instance.activate(env.viewer); + assert.equal(env.instance.coverageStats().hint, TOO_HIGH_HINT); + assert.deepEqual(env.tiles, [], 'nothing asked from up there'); + const release = env.holdTiles(); + env.viewer.view.height = 900; + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().hint, '', 'not while it loads'); + release(); + assert.ok(await until(() => !env.instance.coverageStats().loading)); + assert.equal(env.instance.coverageStats().count, 0); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); +}); + +test('a coverage tile loaded a moment ago is not asked for again', async (t) => { + const env = coverageSetup(t, { + lines: [ + [ + [-83.0458 - 0.001, 42.3314], + [-83.0458 + 0.001, 42.3314], + ], + ], + }); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + // Off and on again (or a pan away and back): drawn from the kept tile. + env.instance.deactivate(); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 1); +}); + +test('a photo no page lists steps to the photos either side of its index', async () => { + const env = await mounted({ sequences: { s: [0, 1, 2, 3, 4, 6, 7, 8, 9] } }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => + id === 's-5' + ? photoRecord('s-5', { seq: 's', index: 5, metres: 25 }) + : getPhoto(id); + await env.instance.viewer.open('s-5'); + await press(env.next, env.poses); + assert.equal(env.poses.at(-1), 's-6'); + await env.instance.viewer.open('s-5'); + await press(env.prev, env.poses); + assert.equal(env.poses.at(-1), 's-4'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 5805672a4..bed8b2ec5 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -6,8 +6,11 @@ import { createLimiter, } from './source.js'; import { + NEAREST_LIMIT, + NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES, + PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, } from './policy.js'; @@ -346,3 +349,93 @@ test('sequence pages run one at a time, the newest first', async () => { await Promise.all(pages); assert.deepEqual(order, ['1', '3', '2']); }); + +/** Every pending promise callback, without moving the mocked clock. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Run `call` on the mocked clock in 100 ms steps until it settles (60 s at + * most). `stamp()` reads the clock, for a fetch to record when it was asked. + */ +async function onMockClock(t, call) { + let now = 0; + const stamp = () => now; + let outcome = null; + const promise = call(stamp); + promise.then( + (value) => (outcome = { value, at: now }), + (error) => (outcome = { error, at: now }), + ); + while (!outcome && now < 60_000) { + await flush(); + if (outcome) break; + t.mock.timers.tick(100); + now += 100; + } + await flush(); + return outcome; +} + +test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and 2 s without one', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + async function waits(retryAfter) { + const asked = []; + const headers = new Map( + retryAfter === undefined ? [] : [['retry-after', retryAfter]], + ); + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: async () => { + asked.push(stamp()); + return asked.length <= PHOTO_API_RETRIES + ? status(429, headers) + : ok(photoPayload({ id: '1' })); + }, + }).getPhoto('1'), + ); + assert.deepEqual(outcome?.value, { id: '1' }, `answered (${retryAfter})`); + return asked.slice(1).map((at, i) => at - asked[i]); + } + assert.deepEqual(await waits('0'), [800, 1600, 3200], 'doubling backoff'); + assert.deepEqual(await waits('5'), [5000, 5000, 5000], 'Retry-After wins'); + assert.deepEqual(await waits('30'), [10_000, 10_000, 10_000], 'capped'); + assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s'); +}); + +test('a lookup that hangs is given up after the photo timeout, once more, then reported', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const asked = []; + const outcome = await onMockClock(t, (stamp) => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + asked.push(stamp()); + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.deepEqual(asked, [0, PHOTO_API_TIMEOUT_MS]); + assert.match(String(outcome?.error?.message), /did not answer in time/); + assert.equal(outcome.at, 2 * PHOTO_API_TIMEOUT_MS); +}); + +test('the nearest lookup asks for the few photos around the point at the search zoom', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ lat: 42.33, lon: -83.04 }); + assert.equal(urls[0].pathname, '/2.0/photo/'); + assert.deepEqual(Object.fromEntries(urls[0].searchParams), { + lat: '42.3300000', + lng: '-83.0400000', + zoomLevel: String(NEAREST_SEARCH_ZOOM), + itemsPerPage: String(NEAREST_LIMIT), + }); + assert.equal(NEAREST_SEARCH_ZOOM, 18); + assert.equal(NEAREST_LIMIT, 10); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 28312a04a..fd3242ef0 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createKartaViewViewer } from './viewer.js'; -import { fakeHost } from '../../../../testSupport/kartaViewFakes.mjs'; +import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs'; const PHOTOS = { flat1: { @@ -49,6 +49,21 @@ const PHOTOS = { pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg', }, }, + pano2: { + id: 'pano2', + sequenceId: 's2', + sequenceIndex: 8, + lon: 106.8401, + lat: -6.19, + heading: 152, + isPano: true, + capturedAt: Date.UTC(2022, 2, 21), + urls: { + view: 'https://storage13.openstreetcam.org/lth/q.jpg', + full: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + pano: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg', + }, + }, }; function fakeRenderer(canvas) { @@ -82,12 +97,15 @@ function setup({ const loads = []; const renderers = []; const stepped = []; + const asked = []; + const attempts = { renderer: 0 }; const viewer = createKartaViewViewer({ getPhoto: async (id) => { if (!PHOTOS[id]) throw new Error('no such photo'); return PHOTOS[id]; }, step: async (photo, delta) => { + asked.push(`${photo.id}:${delta}`); if (failSteps) throw new Error('KartaView did not answer in time'); return steps[`${photo.id}:${delta}`] ?? null; }, @@ -103,6 +121,7 @@ function setup({ return { src: url }; }, createRenderer: (canvas) => { + attempts.renderer++; if (rendererThrows) throw new Error('WebGL is unavailable'); const renderer = fakeRenderer(canvas); renderers.push(renderer); @@ -112,7 +131,16 @@ function setup({ const poses = []; viewer.onPose((pose) => poses.push(pose)); const host = fakeHost(); - return { viewer, poses, host, loads, renderers, stepped }; + return { + viewer, + poses, + host, + loads, + renderers, + stepped, + asked, + attempts, + }; } const parts = (host) => { @@ -245,27 +273,37 @@ test('without WebGL, or when the panorama fails, a 360° photo shows its front v }); test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => { - const { viewer, poses, host, stepped } = setup({ + const { viewer, poses, host, stepped, asked } = setup({ steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' }, }); await viewer.mount(host); await viewer.open('flat1'); const { root, prev, next } = parts(host); - await settle(); - assert.equal(prev.disabled, true, 'flat1 is the first photo: known up front'); + assert.ok( + await until(() => prev.disabled), + 'flat1 is the first photo: known up front', + ); assert.equal(next.disabled, false); next.fire('click'); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(poses.at(-1).imageId, 'flat2'); + assert.ok(await until(() => poses.at(-1).imageId === 'flat2')); assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it'); - assert.equal(next.disabled, true, 'flat2 is the last photo'); + assert.ok(await until(() => next.disabled), 'flat2 is the last photo'); assert.equal(prev.disabled, false); + // A modified arrow, then a plain → (at the end: it only asks). Presses run + // in turn, so once → has asked, a stepping ⌘← would have asked first. + const before = asked.length; root.fire('keydown', { key: 'ArrowLeft', metaKey: true }); + root.fire('keydown', { key: 'ArrowRight' }); + assert.ok(await until(() => asked.length > before)); await settle(); - assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours'); + assert.deepEqual( + asked.slice(before), + ['flat2:1'], + 'modified arrows are not ours', + ); + assert.equal(poses.at(-1).imageId, 'flat2'); root.fire('keydown', { key: 'ArrowLeft' }); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(poses.at(-1).imageId, 'flat1'); + assert.ok(await until(() => poses.at(-1).imageId === 'flat1')); }); test('quick presses each step on from the photo the last one chose', async () => { @@ -279,7 +317,7 @@ test('quick presses each step on from the photo the last one chose', async () => const { next } = parts(host); next.fire('click'); next.fire('click'); - for (let i = 0; i < 8; i++) await settle(); + assert.ok(await until(() => stepped.length === 2), `stepped ${stepped}`); assert.deepEqual(stepped, ['flat2', 'pano1']); }); @@ -318,13 +356,16 @@ test('a panorama is put on screen before its image is sized into the canvas', as }); test('a step that gets no answer is not the end of the sequence', async () => { - const { viewer, host, poses } = setup({ failSteps: true }); + const { viewer, host, poses, asked } = setup({ failSteps: true }); await viewer.mount(host); await viewer.open('flat1'); const { next } = parts(host); next.disabled = false; + // The ends check asked once already; the press asks again and fails. + const asks = () => asked.filter((ask) => ask === 'flat1:1').length; next.fire('click'); - for (let i = 0; i < 4; i++) await settle(); + assert.ok(await until(() => asks() === 2), 'the press asked'); + await settle(); assert.equal(next.disabled, false, 'still enabled: try again'); assert.equal(poses.at(-1).imageId, 'flat1'); }); @@ -332,28 +373,36 @@ test('a step that gets no answer is not the end of the sequence', async () => { test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => { globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); t.after(() => delete globalThis.requestAnimationFrame); - const { viewer, host, renderers, stepped } = setup({ + const { viewer, host, renderers, stepped, asked } = setup({ steps: { 'pano1:1': 'flat1' }, }); await viewer.mount(host); await viewer.open('pano1'); const { root } = parts(host); const view = () => renderers[0].getView(); + const before = asked.length; root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); assert.ok(view().yaw > 0, 'turned right'); root.fire('keydown', { key: 'ArrowUp' }); assert.ok(view().pitch > 0, 'looked up'); - const before = view().fov; + const fov = view().fov; root.fire('keydown', { key: '+' }); - assert.ok(view().fov < before, 'zoomed in'); + assert.ok(view().fov < fov, 'zoomed in'); root.fire('keydown', { key: '-' }); root.fire('keydown', { key: '-' }); - assert.ok(view().fov > before, 'zoomed out'); - for (let i = 0; i < 4; i++) await settle(); - assert.deepEqual(stepped, [], 'none of those stepped'); + assert.ok(view().fov > fov, 'zoomed out'); root.fire('keydown', { key: 'ArrowRight' }); - for (let i = 0; i < 4; i++) await settle(); + assert.ok(await until(() => stepped.length === 1)); assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps'); + // Only that press asked, then flat1's ends check: a Shift+→ that stepped + // would have sent the plain → on from flat1. + assert.ok(await until(() => asked.slice(before).length >= 3)); + await settle(); + assert.deepEqual( + asked.slice(before).sort(), + ['flat1:-1', 'flat1:1', 'pano1:1'], + 'none of the others stepped', + ); }); test('two fingers pinch the panorama’s zoom', async () => { @@ -391,8 +440,7 @@ test('a lost WebGL context drops the panorama and shows the photo again on a new await viewer.open('pano1'); parts(host).canvas.fire('webglcontextlost'); assert.equal(renderers[0].destroyed, true); - for (let i = 0; i < 4; i++) await settle(); - assert.equal(renderers.length, 2, 'a new renderer'); + assert.ok(await until(() => renderers.length === 2), 'a new renderer'); assert.equal(poses.at(-1).imageId, 'pano1'); assert.equal(parts(host).canvas.hidden, false); }); @@ -424,3 +472,263 @@ test('a WebGL failure only lasts until the viewer is mounted again', async () => assert.equal(built.length, 1); assert.equal(parts(again).canvas.hidden, false); }); + +/** Flat photos a → d of one drive, for the tests that hold loads back. */ +const DRIVE = Object.fromEntries( + ['a', 'b', 'c', 'd'].map((id, index) => [ + id, + { + id, + sequenceId: 'drive', + sequenceIndex: index, + lon: 0.1 + index * 0.0001, + lat: 0.1, + heading: 0, + isPano: false, + capturedAt: 0, + urls: { + view: `https://storage2.openstreetcam.org/lth/${id}.jpg`, + full: `https://storage2.openstreetcam.org/proc/${id}.jpg`, + pano: null, + }, + }, + ]), +); +const DRIVE_ORDER = Object.keys(DRIVE); + +/** Which drive photo, at which size, an image address shows. */ +function shows(src) { + const proxied = + /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:(\d+):\d+\/([\w-]+)$/.exec( + src || '', + ); + const url = proxied ? Buffer.from(proxied[2], 'base64url').toString() : src; + const id = /\/(\w+)\.jpg$/.exec(url || '')?.[1] ?? null; + return proxied ? `${id}@${proxied[1]}` : id; +} + +/** + * A viewer whose photo loads (and, with `slowSteps`, step answers) wait for + * the test. ‹ › open through a stand-in core that, like the real one, awaits + * a tick (the viewer mount) before it opens the photo. + */ +function heldSetup({ slowSteps = false } = {}) { + const loads = []; + const asks = []; + const stepped = []; + const shown = []; + const viewer = createKartaViewViewer({ + getPhoto: async (id) => { + if (!DRIVE[id]) throw new Error('no such photo'); + return DRIVE[id]; + }, + step: (photo, delta) => { + const answer = DRIVE_ORDER[DRIVE_ORDER.indexOf(photo.id) + delta] ?? null; + if (!slowSteps) return Promise.resolve(answer); + return new Promise((resolve) => + asks.push({ key: `${photo.id}:${delta}`, resolve }), + ); + }, + onStep: async (id) => { + stepped.push(id); + await settle(); + viewer.open(id).catch(() => {}); + }, + onShown: (photo) => shown.push(photo.id), + load: (url) => + new Promise((resolve, reject) => + loads.push({ + url, + shows: shows(url), + resolve: () => resolve({ src: url }), + reject: () => + reject(new Error('The KartaView photo could not be loaded')), + }), + ), + createRenderer: () => { + throw new Error('no WebGL in Node'); + }, + }); + const poses = []; + viewer.onPose((pose) => poses.push(pose.imageId)); + const host = fakeHost(); + /** Let the load showing `what` (e.g. 'a@1280') finish. */ + const release = async (what) => { + assert.ok( + await until(() => loads.some((load) => load.shows === what)), + `${what} is loading: ${loads.map((load) => load.shows)}`, + ); + const at = loads.findIndex((load) => load.shows === what); + loads.splice(at, 1)[0].resolve(); + await settle(); + }; + /** Answer the oldest pending step ask for `key` (e.g. 'a:1'). */ + const answer = async (key, value) => { + assert.ok(await until(() => asks.some((ask) => ask.key === key))); + const at = asks.findIndex((ask) => ask.key === key); + asks.splice(at, 1)[0].resolve(value); + await settle(); + }; + return { viewer, host, loads, asks, stepped, shown, poses, release, answer }; +} + +test('quick presses while the next photo is still loading each step on from the last one chosen', async () => { + const { viewer, host, stepped, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + await release('a@1280'); + await opening; + const { next } = parts(host); + assert.ok(await until(() => !next.disabled)); + // b, c and d never load: each press steps on from the photo on its way. + next.fire('click'); + next.fire('click'); + next.fire('click'); + assert.ok(await until(() => stepped.length >= 3), `stepped ${stepped}`); + await settle(); + assert.deepEqual(stepped, ['b', 'c', 'd']); +}); + +test('a slow sharp version of an older photo never replaces the newer photo', async () => { + const { viewer, host, shown, poses, release } = heldSetup(); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('b'); + await release('b@1280'); + await second; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + await release('a@2560'); // a's upgrade, overtaken + assert.equal(shows(picture.src), 'b@1280', 'a never comes back'); + await release('b@2560'); + assert.equal(shows(picture.src), 'b@2560', 'b’s own upgrade lands'); + assert.deepEqual(shown, ['a', 'b']); + assert.deepEqual(poses, ['a', 'b']); +}); + +test('an overtaken flat open never shows, nor tells the provider it did', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const first = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + const second = viewer.open('b'); + await release('b@1280'); + await second; + await release('a@1280'); + await first; + const { picture } = parts(host); + assert.equal(shows(picture.src), 'b@1280'); + assert.deepEqual(poses, ['b']); + assert.deepEqual(shown, ['b'], 'onShown only for the photo on screen'); +}); + +test('a photo still loading when the viewer closes stays closed', async () => { + const { viewer, host, shown, poses, loads, release } = heldSetup(); + await viewer.mount(host); + const opening = viewer.open('a'); + assert.ok(await until(() => loads.length === 1), 'a is loading'); + viewer.close(); + await release('a@1280'); + await opening; + const { picture, root } = parts(host); + assert.equal(picture.hidden, true); + assert.equal(root.dataset.kind, ''); + assert.deepEqual(poses, []); + assert.deepEqual(shown, []); +}); + +test('an ends check for an earlier photo does not disable › on the photo now showing', async () => { + const { viewer, host, release, answer } = heldSetup({ slowSteps: true }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + const second = viewer.open('c'); + await release('c@1280'); + await second; + const { prev, next } = parts(host); + await answer('c:-1', 'b'); + await answer('c:1', 'd'); + // a's check answers last, saying nothing lies before or after it. + await answer('a:-1', null); + await answer('a:1', null); + assert.equal(prev.disabled, false); + assert.equal(next.disabled, false, 'c still has a next photo'); +}); + +test('a press whose answer comes after another photo was opened is dropped', async () => { + const { viewer, host, stepped, release, answer, asks } = heldSetup({ + slowSteps: true, + }); + await viewer.mount(host); + const first = viewer.open('a'); + await release('a@1280'); + await first; + await answer('a:-1', null); + await answer('a:1', 'b'); + const { next } = parts(host); + next.disabled = false; + next.fire('click'); + assert.ok(await until(() => asks.some((ask) => ask.key === 'a:1'))); + // The user picks another photo meanwhile. + const other = viewer.open('d'); + await release('d@1280'); + await other; + await answer('a:1', 'b'); + await settle(); + assert.deepEqual(stepped, [], 'the old press does not open b over d'); +}); + +test('a failed open does not become the photo ‹ › step from', async () => { + const { viewer, host, stepped } = setup({ steps: { 'flat1:1': 'flat2' } }); + await viewer.mount(host); + await viewer.open('flat1'); + await assert.rejects(viewer.open('nope'), /no such photo/); + parts(host).next.disabled = false; + parts(host).next.fire('click'); + assert.ok(await until(() => stepped.length === 1)); + assert.deepEqual(stepped, ['flat2'], 'from the photo still showing'); +}); + +test('WebGL is tried once per mount, not again for every 360° photo', async () => { + const { viewer, host, attempts } = setup({ rendererThrows: true }); + await viewer.mount(host); + await viewer.open('pano1'); + await viewer.open('pano2'); + assert.equal(attempts.renderer, 1); + assert.equal(parts(host).picture.hidden, false, 'front views instead'); +}); + +test('a step button that disables under the keyboard hands focus to the viewer', async () => { + const { viewer, host } = setup(); + await viewer.mount(host); + const { root, prev, next } = parts(host); + let focused = 0; + root.focus = () => focused++; + next.ownerDocument = { activeElement: next }; + prev.ownerDocument = { activeElement: root }; + await viewer.open('flat1'); + assert.ok(await until(() => next.disabled && prev.disabled)); + assert.equal(focused, 1, 'only for the focused button'); +}); + +test('› to another 360° photo keeps the look direction; opening one afresh faces its heading', async (t) => { + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + t.after(() => delete globalThis.requestAnimationFrame); + const { viewer, host, renderers, poses } = setup({ + steps: { 'pano1:1': 'pano2' }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + const { root, next } = parts(host); + root.fire('keydown', { key: 'ArrowRight', shiftKey: true }); + const yaw = renderers[0].getView().yaw; + assert.ok(yaw > 0); + next.fire('click'); + assert.ok(await until(() => poses.at(-1).imageId === 'pano2')); + assert.equal(renderers[0].getView().yaw, yaw, 'still looking the same way'); + await viewer.open('pano1'); + assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead'); +}); diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index f7da6412e..070fe3d91 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -659,3 +659,17 @@ test('FIT/FILL stays hidden for a second Street View panorama after a close', as await host.open('google', 'g-2'); assert.equal(state.street.renderModes, false); }); + +test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => { + const adapter = fakeAdapter(); + let frameable = false; + adapter.setRenderMode = () => {}; + adapter.supportsRenderModes = () => frameable; + const { state, host } = harness(adapter); + await host.open('mapillary', 'pano-1'); + assert.equal(state.street.imageId, 'pano-1', 'the pose arrived'); + assert.equal(state.street.renderModes, false); + frameable = true; + await host.open('mapillary', 'flat-1'); + assert.equal(state.street.renderModes, true, 'a flat image can be framed'); +}); diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index 4bd601e21..142b2d39c 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -58,3 +58,16 @@ export function fakeHost() { host.ownerDocument = doc; return host; } + +/** + * Wait, a macrotask at a time, until `condition` holds; false if it never + * does within `ticks`. A test asserting that something did not happen waits + * on a sign that the work is done instead of a fixed count of ticks. + */ +export async function until(condition, { ticks = 200 } = {}) { + for (let i = 0; i < ticks; i++) { + if (await condition()) return true; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return Boolean(await condition()); +} diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index b3d6c3a13..73702c1f1 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -1,6 +1,11 @@ /** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */ import * as Cesium from 'cesium'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; +import { + latToTileY, + lonToTileX, + tileBounds, +} from '../layers/streetLevel/tileMath.js'; const RAD = Math.PI / 180; @@ -141,3 +146,92 @@ export function rayCamera({ computeViewRectangle: () => undefined, }; } + +/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */ +export function tileCentre(lon, lat, z = 14) { + const x = lonToTileX(lon, z); + const y = latToTileY(lat, z); + const { west, east, south, north } = tileBounds(x, y, z); + return { + tile: { x, y, z }, + lon: (west + east) / 2, + lat: (south + north) / 2, + }; +} + +/** A scene collection that remembers what is on the globe. */ +function sceneCollection() { + const items = new Set(); + return { + items, + add(primitive) { + items.add(primitive); + return primitive; + }, + remove(primitive) { + return items.delete(primitive); + }, + }; +} + +/** + * A viewer looking straight down on a 0.004° square around `view` + * (`{lon, lat, height}`, mutable), enough for line coverage to attach, + * refresh and draw. Cesium's one-time terrain-height download is skipped. + */ +export function coverageViewer(view, t) { + const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights; + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () => + Promise.resolve(); + t?.after(() => { + Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize; + }); + // Node has no WebGL context to report line-width limits. + Cesium.ContextLimits._minimumAliasedLineWidth = 1; + Cesium.ContextLimits._maximumAliasedLineWidth = 10; + const listeners = () => { + const set = new Set(); + return { + set, + addEventListener(listener) { + set.add(listener); + return () => set.delete(listener); + }, + }; + }; + return { + view, + scene: { + canvas: { clientWidth: 100, clientHeight: 100 }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + updateHeight: () => () => {}, + getHeight: () => undefined, + groundPrimitives: sceneCollection(), + primitives: sceneCollection(), + postRender: listeners(), + preRender: listeners(), + }, + camera: { + changed: new Cesium.Event(), + moveEnd: new Cesium.Event(), + get positionWC() { + return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height); + }, + get positionCartographic() { + return { + longitude: view.lon * RAD, + latitude: view.lat * RAD, + height: view.height, + }; + }, + pickEllipsoid(point) { + return Cesium.Cartesian3.fromDegrees( + view.lon - 0.002 + (point.x / 100) * 0.004, + view.lat + 0.002 - (point.y / 100) * 0.004, + ); + }, + computeViewRectangle: () => undefined, + }, + }; +} diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 33ae4e4a0..11680c5d3 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -26,9 +26,13 @@ import { KV_LINE, KV_SEQUENCE_ID, kartaViewPhotos, + resetKartaViewFixture, } from '../../scripts/fixtures/street-level/kartaView.mjs'; import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; -import { normalizePhoto } from '../layers/streetLevel/providers/kartaview/policy.js'; +import { + normalizePhoto, + resizedImageUrl, +} from '../layers/streetLevel/providers/kartaview/policy.js'; // The press helpers wait for paint; Node has no frames, so a tick stands in. globalThis.requestAnimationFrame ??= (callback) => @@ -665,10 +669,16 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`, }); assert.deepEqual(body(empty).lines, []); - const near = answerKartaViewRequest({ - method: 'GET', - url: `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`, - }); + const nearUrl = `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`; + resetKartaViewFixture(); + // The first lookup is rate-limited, readable cross-origin; the retry answers. + const limited = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(limited.status, 429); + assert.equal(limited.known, true); + assert.equal(limited.headers['Retry-After'], '0'); + assert.match(limited.headers['Access-Control-Expose-Headers'], /Retry-After/); + const near = answerKartaViewRequest({ method: 'GET', url: nearUrl }); + assert.equal(near.status, 200); const records = body(near).result.data; assert.ok(records.length > 1); assert.ok(Number(records[0].distance) <= Number(records[1].distance)); @@ -678,6 +688,42 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known, true, ); + // Every odd photo is archived: its file answers 409, an even one the image. + const photos = kartaViewPhotos(); + const storageAnswer = (index) => + answerKartaViewRequest({ + method: 'GET', + url: normalizePhoto( + body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${photos[index].id}`, + }), + ).result.data, + ).urls.view, + }); + assert.equal(photos[3].archived, true); + assert.equal(storageAnswer(3).status, 409); + assert.equal(storageAnswer(3).known, true); + assert.equal(photos[4].archived, false); + assert.equal(storageAnswer(4).status, 200); + assert.equal(storageAnswer(4).contentType, 'image/jpeg'); + // The image proxy still serves an archived photo: the 360° step opens one. + const pano = photos.find((p) => p.isPano); + assert.equal(pano.archived, true); + const panoRecord = body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/${pano.id}`, + }), + ).result.data; + assert.equal( + answerKartaViewRequest({ + method: 'GET', + url: resizedImageUrl(normalizePhoto(panoRecord).urls.pano, [1024, 512]), + }).status, + 200, + ); assert.equal( answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known, false,