mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +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
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user