mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
bd0bbf5134
commit
3d4c6de21a
14 files changed
+1374
-81
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user