test(street-level): harden KartaView tests (mutation score 51% → 95%)

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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-13 20:43:27 +01:00
1 parent bd0bbf5134
commit 3d4c6de21a
14 files changed
+1374 -81

No files matched your search

+35 -6
View File
@@ -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
+1
View File
@@ -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",
+39 -7
View File
@@ -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)
+61 -2
View File
@@ -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,
);
});
@@ -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');
});
@@ -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]);
});
@@ -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\//,
);
});
@@ -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');
});
@@ -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);
});
@@ -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');
});
@@ -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');
});
+13
View File
@@ -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());
}
+94
View File
@@ -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,
},
};
}
+51 -5
View File
@@ -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,