mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
test(street-level): close the mutation survivors; Esc in rich text
A mutation review left 18 earlier survivors; each now has a test that fails with the mutant applied: - the expanded viewer returns into the panel on shrink, close and destroy (the fake DOM had no parentNode, so reinsertion was never exercised); the fake DOM gains offsetParent and a MutationObserver that honours its options, and a <body> child added while expanded goes inert; - each pose moves the marker, drives FOLLOW and selects a new sequence; close() retires an open still mounting or opening; a provider toggled while the layer is off activates nothing; the adapter drops late and superseded poses; - share-link defaults: "any date" (0 days), the codec max pinned to the filter's MAX_SINCE_DAYS (layerState cannot import it across package boundaries, so a test ties the two), Mapillary on when a link omits it; - Esc that something else handled, or typed in a field, keeps the selection; a key problem outranks LOADING on the pill; - the marker's terrain height (mesh, DEM, clamp then re-place); - proxy: LRU memory cache and its half-budget rule, Retry-After capped, a request after a cancelled fetch starts a fresh one; - date-line tile ranking with and without focus; the horizon cull idles while the camera stands still; the old zoom goes as soon as the new one lands; a rate limit holds every refresh inside its wait. One real bug found and fixed: Esc inside a contenteditable cleared the selected sequence (selection.js now ignores isContentEditable targets). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9dde7b4a62
commit
3af0f80cdb
15 files changed
+1198
-30
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
resolveFilter,
|
||||
sameFilter,
|
||||
} from './filter.js';
|
||||
import { MAX_SINCE_DAYS } from './policy.js';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
@@ -81,3 +82,20 @@ test('sameFilter compares the stored form', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeFilter takes "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||
// Dragging SINCE back to ANY DATE, or a link that says so, must stick.
|
||||
assert.equal(
|
||||
normalizeFilter({ sinceDays: 0 }, { pano: 'all', sinceDays: 365 })
|
||||
.sinceDays,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeFilter(
|
||||
{ sinceDays: MAX_SINCE_DAYS },
|
||||
{ pano: 'all', sinceDays: 0 },
|
||||
).sinceDays,
|
||||
MAX_SINCE_DAYS,
|
||||
'the longest window is kept whole',
|
||||
);
|
||||
});
|
||||
@@ -419,3 +419,66 @@ test('the Mapillary provider hands the lookup signal to its source (review IC8 P
|
||||
);
|
||||
assert.equal(requests[0]?.signal, signal);
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||
assert.equal(layer.getParams().sinceDays, 365);
|
||||
layer.setParams({ sinceDays: 0 });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'flat',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.equal(
|
||||
provider.filters.at(-1).sinceMs,
|
||||
null,
|
||||
'providers drop the cut-off',
|
||||
);
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
});
|
||||
|
||||
/** A layer initialised on a stand-in viewer but never enabled. */
|
||||
function initialisedLayer(t, providers) {
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const layer = createStreetLevelLayer({ providers });
|
||||
layer.init(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
return { layer, viewer };
|
||||
}
|
||||
|
||||
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const prewarmed = [];
|
||||
const create = provider.create;
|
||||
provider.create = (context) => {
|
||||
const instance = create(context);
|
||||
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
||||
return instance;
|
||||
};
|
||||
const { layer, viewer } = initialisedLayer(t, [provider]);
|
||||
layer.attachViewerHost({});
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
layer.setParams({ mapillary: false });
|
||||
layer.setParams({ mapillary: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
||||
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
||||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
||||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||
// Enabling the layer is what activates it, once.
|
||||
layer.enable(viewer);
|
||||
assert.equal(provider.calls.activate, 1);
|
||||
assert.equal(viewer.credits.length, 1);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMarker } from './marker.js';
|
||||
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
|
||||
|
||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||
const noop = () => {};
|
||||
@@ -11,7 +12,7 @@ globalThis.document ??= {
|
||||
};
|
||||
|
||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||
function setup() {
|
||||
function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
const viewer = {
|
||||
@@ -33,7 +34,7 @@ function setup() {
|
||||
const state = {
|
||||
services: {},
|
||||
viewer,
|
||||
surface: 'draped',
|
||||
surface,
|
||||
marker: {
|
||||
// Stands in for the billboard collection; ensure() keeps it.
|
||||
collection: {
|
||||
@@ -44,7 +45,7 @@ function setup() {
|
||||
billboard: null,
|
||||
},
|
||||
};
|
||||
const marker = createMarker({ state, parts: {} });
|
||||
const marker = createMarker({ state, parts });
|
||||
const frame = () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
};
|
||||
@@ -76,3 +77,113 @@ test('destroying the marker stops the horizon cull', () => {
|
||||
marker.destroy();
|
||||
assert.equal(preRender.size, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Bare earth from a ground caster (null until its cell is fetched) and the
|
||||
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
|
||||
*/
|
||||
function terrainParts({ dem = null, mesh } = {}) {
|
||||
const ground = { dem, prepares: [] };
|
||||
const sampler = { mesh, requests: [], listeners: [] };
|
||||
return {
|
||||
ground,
|
||||
sampler,
|
||||
parts: {
|
||||
groundCaster: {
|
||||
groundAt: () => ground.dem,
|
||||
prepare(points) {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
ground.prepares.push({ points, resolve });
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
meshSampler: {
|
||||
meshAt: () => sampler.mesh,
|
||||
request: (points) => sampler.requests.push(points),
|
||||
onSampled: (listener) => sampler.listeners.push(listener),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const heightOf = (billboard) =>
|
||||
Cesium.Cartographic.fromCartesian(billboard.position).height;
|
||||
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||
|
||||
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
||||
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
|
||||
const { parts, sampler } = terrainParts({ dem: 10 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
|
||||
// The sample lands: the marker moves onto the road surface.
|
||||
sampler.mesh = 10.5;
|
||||
for (const listener of sampler.listeners) listener();
|
||||
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
|
||||
});
|
||||
|
||||
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
|
||||
const { parts, ground } = terrainParts({ mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(
|
||||
billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
||||
});
|
||||
|
||||
test('ground that arrives after the marker moved on does not move it back', async () => {
|
||||
const { parts, ground } = terrainParts();
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 0);
|
||||
const elsewhere = { lon: -121.5, lat: 38.6 };
|
||||
marker.set(elsewhere, 0);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const placed = Cesium.Cartographic.fromCartesian(
|
||||
state.marker.billboard.position,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('draped mode clamps the marker to the globe whatever the ground says', () => {
|
||||
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'draped' });
|
||||
marker.set(SPOT, 0);
|
||||
assert.equal(
|
||||
state.marker.billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(ground.prepares.length, 0);
|
||||
});
|
||||
@@ -38,3 +38,21 @@ test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
||||
assert.equal(empty.providers.size, 0);
|
||||
assert.deepEqual(empty.filter, current);
|
||||
});
|
||||
|
||||
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||
const decoded = decodeParams(
|
||||
{ sinceDays: 0 },
|
||||
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
|
||||
);
|
||||
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
|
||||
// The provider switch off is a value too, not an absence.
|
||||
assert.deepEqual(
|
||||
[
|
||||
...decodeParams(
|
||||
{ mapillary: false },
|
||||
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
|
||||
).providers,
|
||||
],
|
||||
[['mapillary', false]],
|
||||
);
|
||||
});
|
||||
@@ -839,3 +839,57 @@ test('only lines within the mesh sampler range of the camera are asked for (revi
|
||||
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [old] = viewer.scene.groundPrimitives.items;
|
||||
assert.ok(old, 'the z14 lines are drawn');
|
||||
viewer.view.height = 3000; // zoom out to z13
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.zoom, 13);
|
||||
const fresh = source.calls.slice(1);
|
||||
assert.ok(fresh.length > 0, 'the new zoom is loading');
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
|
||||
assert.ok(viewer.scene.groundPrimitives.items.has(old));
|
||||
for (const call of fresh) {
|
||||
assert.ok(
|
||||
viewer.scene.groundPrimitives.items.has(old),
|
||||
'kept while a new tile is still loading',
|
||||
);
|
||||
call.resolve(new Uint8Array());
|
||||
await settle();
|
||||
}
|
||||
// The last new tile is in: the old lines go now, long before the wait.
|
||||
assert.equal(state.coverage.pending.size, 0);
|
||||
assert.equal(state.coverage.stale.size, 0);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
||||
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, coverage } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].reject(
|
||||
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
|
||||
t.mock.timers.tick(28_000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
@@ -36,8 +36,11 @@ export function createSelection({ state, parts }) {
|
||||
*/
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
// A text field keeps its Esc, rich-text editors (contenteditable) included.
|
||||
const target = event.target;
|
||||
if (
|
||||
event.target?.closest?.(
|
||||
target?.isContentEditable === true ||
|
||||
target?.closest?.(
|
||||
'.panel-collapsible, [role="dialog"], input, textarea, select',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createSelection } from './selection.js';
|
||||
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
||||
* has a known timeline.
|
||||
*/
|
||||
function harness() {
|
||||
function harness({ selected = false } = {}) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
@@ -17,6 +17,23 @@ function harness() {
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||
globalThis.document = new EventTarget();
|
||||
// The document's keydown listeners, so a test can hand them any target.
|
||||
const keyListeners = new Set();
|
||||
const addListener = globalThis.document.addEventListener.bind(
|
||||
globalThis.document,
|
||||
);
|
||||
const removeListener = globalThis.document.removeEventListener.bind(
|
||||
globalThis.document,
|
||||
);
|
||||
globalThis.document.addEventListener = (type, listener, options) => {
|
||||
if (type === 'keydown') keyListeners.add(listener);
|
||||
addListener(type, listener, options);
|
||||
};
|
||||
globalThis.document.removeEventListener = (type, listener, options) => {
|
||||
if (type === 'keydown') keyListeners.delete(listener);
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
const scene = { under: null };
|
||||
@@ -48,8 +65,11 @@ function harness() {
|
||||
state,
|
||||
parts: {
|
||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||
hasSelectedSequence: () => false,
|
||||
clearSequences() {},
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
clearSequences() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
selection.install(viewer);
|
||||
@@ -58,6 +78,20 @@ function harness() {
|
||||
);
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
/** A keydown reaching the document from `target`; the event as handled. */
|
||||
key(key, target, { defaultPrevented = false } = {}) {
|
||||
const event = {
|
||||
key,
|
||||
target,
|
||||
defaultPrevented,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
};
|
||||
for (const listener of [...keyListeners]) listener(event);
|
||||
return event;
|
||||
},
|
||||
scene,
|
||||
canvas,
|
||||
camera,
|
||||
@@ -168,3 +202,58 @@ test('a hover frame queued before the layer went off does not pick', () => {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An element as Esc sees it: `closest` matches the tag names a selector list
|
||||
* names, and `isContentEditable` is what the browser reports, inherited from
|
||||
* an editing host.
|
||||
*/
|
||||
function element(tag, { isContentEditable = false } = {}) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
isContentEditable,
|
||||
closest(selector) {
|
||||
const names = selector.split(',').map((part) => part.trim());
|
||||
return names.includes(tag) ? this : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Esc on the globe clears the selected sequence', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
const event = h.key('Escape', element('canvas'));
|
||||
assert.equal(h.sequences.cleared, 1);
|
||||
assert.equal(event.defaultPrevented, true, 'and claims the key');
|
||||
h.key('Escape', element('canvas'));
|
||||
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an Esc something else already handled keeps the selection (M60)', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
h.key('Escape', element('canvas'), { defaultPrevented: true });
|
||||
assert.equal(h.sequences.cleared, 0);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
|
||||
const h = harness({ selected: true });
|
||||
try {
|
||||
for (const tag of ['input', 'textarea', 'select']) {
|
||||
const event = h.key('Escape', element(tag));
|
||||
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
|
||||
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
|
||||
}
|
||||
// A <div contenteditable> (or anything inside one) is a text field too.
|
||||
h.key('Escape', element('div', { isContentEditable: true }));
|
||||
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -108,6 +108,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si
|
||||
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
||||
});
|
||||
|
||||
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
|
||||
const z = 14;
|
||||
const n = 2 ** z;
|
||||
const bbox = [179.9, 0, -179.9, 0.1];
|
||||
const key = (t) => `${t.x}/${t.y}`;
|
||||
const keys = (list) => new Set(list.map(key));
|
||||
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||
const plain = tilesForBbox(bbox, z);
|
||||
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
||||
// The box centre is the date line itself, not the middle of the map: the
|
||||
// middle two rows of the columns touching it come first, then the ring
|
||||
// around them, all within two columns of the line.
|
||||
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
||||
assert.deepEqual(
|
||||
keys(plain.tiles.slice(0, 4)),
|
||||
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
||||
);
|
||||
for (const tile of plain.tiles.slice(4, 12))
|
||||
assert.ok(
|
||||
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||
`${key(tile)} near the line`,
|
||||
);
|
||||
// A line of sight across the date line just north of the equator: the two
|
||||
// tiles it crosses come first, then the two in the row below them.
|
||||
const row = latToTileY(0.05, z);
|
||||
const from = { lon: 179.99, lat: 0.05 };
|
||||
const to = { lon: -179.99, lat: 0.05 };
|
||||
for (const focus of [
|
||||
{ from, to },
|
||||
{ from: to, to: from },
|
||||
]) {
|
||||
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(0, 2)),
|
||||
new Set([`${n - 1}/${row}`, `0/${row}`]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(2, 4)),
|
||||
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
|
||||
// A box that runs far north of the camera, as a tilted view's does: the
|
||||
// box centre is kilometres from both the camera and the screen centre.
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cameraHeightAboveGround,
|
||||
createHorizonCull,
|
||||
metresBetween,
|
||||
viewCentre,
|
||||
viewFocus,
|
||||
@@ -167,3 +168,100 @@ test('metresBetween measures the short way round the date line (review IC8 P2)',
|
||||
) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A horizon cull over `items` for a camera the test moves, with the frame's
|
||||
* pre-render listeners and a count of the points tested against the horizon.
|
||||
*/
|
||||
function cullHarness(t, items) {
|
||||
const visible = t.mock.method(
|
||||
Cesium.EllipsoidalOccluder.prototype,
|
||||
'isPointVisible',
|
||||
);
|
||||
const preRender = new Set();
|
||||
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let changes = 0;
|
||||
const cull = createHorizonCull({
|
||||
getViewer: () => viewer,
|
||||
items: () => items,
|
||||
onChange: () => changes++,
|
||||
});
|
||||
return {
|
||||
cull,
|
||||
preRender,
|
||||
tested: () => visible.mock.callCount(),
|
||||
changes: () => changes,
|
||||
frame: () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
},
|
||||
// Moved in place, as Cesium updates positionWC.
|
||||
moveTo: (lon, lat) =>
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
1e7,
|
||||
undefined,
|
||||
camera.positionWC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const point = (lon, lat) => ({
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
show: true,
|
||||
});
|
||||
|
||||
test('the horizon cull does no work while the camera stands still', (t) => {
|
||||
const near = point(0, 0);
|
||||
const far = point(180, 0);
|
||||
const h = cullHarness(t, [near, far]);
|
||||
h.cull.update();
|
||||
assert.equal(h.tested(), 2);
|
||||
assert.deepEqual([near.show, far.show], [true, false]);
|
||||
assert.equal(h.preRender.size, 1, 'listening for camera moves');
|
||||
for (let i = 0; i < 5; i++) h.frame();
|
||||
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
|
||||
h.moveTo(180, 0);
|
||||
h.frame();
|
||||
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
|
||||
assert.deepEqual([near.show, far.show], [false, true]);
|
||||
h.frame();
|
||||
h.frame();
|
||||
assert.equal(h.tested(), 4, 'and then rests again');
|
||||
h.cull.stop();
|
||||
assert.equal(h.preRender.size, 0);
|
||||
});
|
||||
|
||||
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
|
||||
const items = [point(0, 0)];
|
||||
const h = cullHarness(t, items);
|
||||
h.cull.update();
|
||||
assert.equal(h.preRender.size, 1);
|
||||
// Every item goes (the tiles are dropped); the next camera move finds none.
|
||||
items.length = 0;
|
||||
h.moveTo(10, 0);
|
||||
h.frame();
|
||||
assert.equal(h.preRender.size, 0, 'the listener is removed');
|
||||
// New items arrive: they are culled now, and camera moves are watched again.
|
||||
const far = point(-170, 0);
|
||||
items.push(far);
|
||||
h.cull.update([far]);
|
||||
assert.equal(far.show, false, 'culled on arrival');
|
||||
assert.equal(h.preRender.size, 1, 'the listener is back');
|
||||
h.moveTo(-170, 0);
|
||||
h.frame();
|
||||
assert.equal(far.show, true, 're-culled when the camera moves');
|
||||
assert.equal(h.changes(), 2, 'a redraw for each change');
|
||||
h.cull.stop();
|
||||
});
|
||||
@@ -30,6 +30,11 @@ registerHooks({
|
||||
const { createMapillaryViewer } =
|
||||
await import('./providers/mapillary/viewer.js');
|
||||
|
||||
/**
|
||||
* A viewer host over `adapter`. `marker` records where the position marker
|
||||
* was put (null once cleared), `followed` counts camera-follow updates and
|
||||
* `selected` every sequence the provider was asked to select.
|
||||
*/
|
||||
function harness(adapter) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
@@ -38,17 +43,44 @@ function harness(adapter) {
|
||||
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||
};
|
||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||
state.providers.set('mapillary', { def, instance: { viewer: adapter } });
|
||||
const selected = [];
|
||||
let selectedId = null;
|
||||
const instance = {
|
||||
viewer: adapter,
|
||||
selectSequence(id) {
|
||||
selected.push(id);
|
||||
selectedId = id;
|
||||
},
|
||||
sequenceStats: () => ({ selectedId }),
|
||||
};
|
||||
state.providers.set('mapillary', { def, instance });
|
||||
const framing = { started: 0, cancelled: 0 };
|
||||
const marker = [];
|
||||
const followed = { count: 0 };
|
||||
const parts = {
|
||||
marker: { set() {}, clear() {} },
|
||||
marker: {
|
||||
set: (position, bearing) =>
|
||||
marker.push(position && { position, bearing }),
|
||||
clear: () => marker.push(null),
|
||||
},
|
||||
follow: {
|
||||
followCamera() {},
|
||||
followCamera: () => followed.count++,
|
||||
lookAtPosition: () => framing.started++,
|
||||
cancelFraming: () => framing.cancelled++,
|
||||
},
|
||||
};
|
||||
return { state, framing, host: createViewerHost({ state, parts }) };
|
||||
return {
|
||||
state,
|
||||
framing,
|
||||
marker,
|
||||
followed,
|
||||
selected,
|
||||
/** Esc on the map: the provider drops its selected sequence. */
|
||||
clearSequence() {
|
||||
selectedId = null;
|
||||
},
|
||||
host: createViewerHost({ state, parts }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +88,7 @@ function harness(adapter) {
|
||||
* construction (`gate` holds it open) and `unmount` destroys the viewer that
|
||||
* every later `open` needs.
|
||||
*/
|
||||
function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
||||
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
||||
let emit = null;
|
||||
let mounted = false;
|
||||
@@ -70,7 +102,7 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
mounted = true;
|
||||
},
|
||||
/** A pose event from the library, as MapillaryJS fires them. */
|
||||
emitPose(id) {
|
||||
emitPose(id, overrides = {}) {
|
||||
emit?.({
|
||||
providerId: 'mapillary',
|
||||
imageId: id,
|
||||
@@ -79,12 +111,15 @@ function fakeAdapter({ failMounts = 0, gate = null } = {}) {
|
||||
bearing: 90,
|
||||
isPano: false,
|
||||
externalUrl: `https://example.test/${id}`,
|
||||
...overrides,
|
||||
});
|
||||
},
|
||||
/** Like MapillaryJS: the image event comes first, then `moveTo` settles. */
|
||||
async open(id) {
|
||||
if (!mounted) throw new Error('viewer is not mounted');
|
||||
calls.open.push(id);
|
||||
this.emitPose(id);
|
||||
await openGate?.promise;
|
||||
},
|
||||
close() {},
|
||||
unmount() {
|
||||
@@ -220,10 +255,115 @@ test('switching the layer off and on during a cold first open keeps the shared v
|
||||
assert.equal(state.street.imageId, 'c');
|
||||
});
|
||||
|
||||
/** MapillaryJS, counting live viewers (each holds a WebGL context). */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('every pose the viewer reports moves the marker and the followed camera', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { state, host, marker, followed } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||
assert.equal(followed.count, 1);
|
||||
// The user walks on, with FOLLOW on: the globe camera goes along.
|
||||
state.street.follow = true;
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||
assert.deepEqual(marker.at(-1), {
|
||||
position: { lon: 3, lat: 4 },
|
||||
bearing: 180,
|
||||
});
|
||||
assert.equal(followed.count, 2);
|
||||
// Looking around in place is a pose too.
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
|
||||
assert.equal(marker.at(-1).bearing, 270);
|
||||
assert.equal(followed.count, 3);
|
||||
});
|
||||
|
||||
test('a pose on another sequence selects it, but not while the image still loads', async () => {
|
||||
const openGate = deferred();
|
||||
const adapter = fakeAdapter({ openGate });
|
||||
const { state, host, selected, clearSequence } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a');
|
||||
await settle();
|
||||
assert.equal(state.street.loading, true, 'the image is downloading');
|
||||
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
||||
assert.deepEqual(selected, [], 'no sequence lookup competes with it');
|
||||
openGate.resolve();
|
||||
assert.equal(await opening, true);
|
||||
assert.deepEqual(selected, ['seq-a']);
|
||||
// Stepping onto the next sequence (an arrow at a junction) selects it …
|
||||
adapter.emitPose('b');
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
// … and walking along it does not ask again.
|
||||
adapter.emitPose('c', { sequenceId: 'seq-b' });
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
// Once Esc cleared it, looking around on the same sequence leaves it so.
|
||||
clearSequence();
|
||||
adapter.emitPose('c', { sequenceId: 'seq-b', bearing: 10 });
|
||||
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||
});
|
||||
|
||||
test('closing the photo clears the marker', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { host, marker } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.notEqual(marker.at(-1), null);
|
||||
host.close();
|
||||
assert.equal(marker.at(-1), null);
|
||||
});
|
||||
|
||||
test('an open still mounting when the photo is closed does nothing when the mount lands', async () => {
|
||||
const gate = deferred();
|
||||
const adapter = fakeAdapter({ gate });
|
||||
const { state, host, framing, selected } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
|
||||
await settle();
|
||||
host.close();
|
||||
gate.resolve();
|
||||
assert.equal(await opening, false);
|
||||
assert.deepEqual(adapter.calls.open, [], 'the image is never opened');
|
||||
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||
assert.deepEqual(selected, []);
|
||||
assert.deepEqual(
|
||||
[state.street.open, state.street.loading, state.street.imageId],
|
||||
[false, false, null],
|
||||
);
|
||||
// The mounted viewer serves the next open.
|
||||
assert.equal(await host.open('mapillary', 'b'), true);
|
||||
assert.equal(state.street.imageId, 'b');
|
||||
});
|
||||
|
||||
test('an image still opening when the photo is closed is neither framed nor selected', async () => {
|
||||
const openGate = deferred();
|
||||
const adapter = fakeAdapter({ openGate });
|
||||
const { state, host, framing, selected } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a');
|
||||
await settle();
|
||||
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
||||
host.close();
|
||||
openGate.resolve();
|
||||
assert.equal(await opening, false);
|
||||
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||
assert.deepEqual(selected, []);
|
||||
assert.deepEqual(
|
||||
[state.street.open, state.street.loading, state.street.imageId],
|
||||
[false, false, null],
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* MapillaryJS, counting live viewers (each holds a WebGL context). A test can
|
||||
* hold `moveTo(id)` on `viewers.moveGates.get(id)` and every position read on
|
||||
* `viewers.positionGate`, and fire a viewer's events through `handlers`.
|
||||
*/
|
||||
function fakeLibrary() {
|
||||
const gate = deferred();
|
||||
const viewers = { created: 0, live: 0, instances: [] };
|
||||
const viewers = {
|
||||
created: 0,
|
||||
live: 0,
|
||||
instances: [],
|
||||
moveGates: new Map(),
|
||||
moves: [],
|
||||
positionGate: null,
|
||||
};
|
||||
class Viewer {
|
||||
constructor() {
|
||||
viewers.created++;
|
||||
@@ -231,21 +371,27 @@ function fakeLibrary() {
|
||||
viewers.instances.push(this);
|
||||
this.removed = false;
|
||||
this.playback = { stops: 0 };
|
||||
this.handlers = {};
|
||||
}
|
||||
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
|
||||
getComponent(name) {
|
||||
if (name !== 'sequence') return undefined;
|
||||
return { stop: () => this.playback.stops++ };
|
||||
}
|
||||
on() {}
|
||||
on(type, handler) {
|
||||
this.handlers[type] = handler;
|
||||
}
|
||||
async getPosition() {
|
||||
await viewers.positionGate?.promise;
|
||||
return { lng: 1, lat: 2 };
|
||||
}
|
||||
async getPointOfView() {
|
||||
return { bearing: 10, tilt: 0 };
|
||||
}
|
||||
async moveTo(id) {
|
||||
return { id, cameraType: 'perspective', sequenceId: 's' };
|
||||
viewers.moves.push(id);
|
||||
await viewers.moveGates.get(id)?.promise;
|
||||
return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
|
||||
}
|
||||
remove() {
|
||||
if (this.removed) return;
|
||||
@@ -337,3 +483,67 @@ test('closing the photo or switching the layer off stops its framing flight (rev
|
||||
host.unmount();
|
||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
||||
});
|
||||
|
||||
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
|
||||
async function mountedMapillary() {
|
||||
const library = fakeLibrary();
|
||||
library.gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
const poses = [];
|
||||
adapter.onPose((pose) => poses.push(pose));
|
||||
await adapter.mount({});
|
||||
return { ...library, adapter, poses };
|
||||
}
|
||||
|
||||
test('a Mapillary pose still being read when the photo closes is never emitted (M57)', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
await adapter.open('a');
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['a'],
|
||||
);
|
||||
// The library steps to the next image; its position is still being read.
|
||||
const [viewer] = viewers.instances;
|
||||
viewers.positionGate = deferred();
|
||||
viewer.handlers.image({
|
||||
image: { id: 'b', cameraType: 'perspective', sequenceId: 'seq-b' },
|
||||
});
|
||||
adapter.close();
|
||||
viewers.positionGate.resolve();
|
||||
await settle();
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['a'],
|
||||
'the closed photo does not come back',
|
||||
);
|
||||
});
|
||||
|
||||
test('a Mapillary image overtaken by a newer open emits no pose when it lands (M58)', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
const gateA = deferred();
|
||||
const gateB = deferred();
|
||||
viewers.moveGates.set('a', gateA);
|
||||
viewers.moveGates.set('b', gateB);
|
||||
const openA = adapter.open('a');
|
||||
await settle(); // A's image is downloading …
|
||||
const openB = adapter.open('b'); // … when the user picks another one
|
||||
gateA.resolve();
|
||||
await openA;
|
||||
assert.deepEqual(poses, [], 'nothing for the image the user left');
|
||||
gateB.resolve();
|
||||
await openB;
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => [pose.imageId, pose.sequenceId]),
|
||||
[['b', 'seq-b']],
|
||||
);
|
||||
});
|
||||
|
||||
test('back-to-back Mapillary opens download only the newer image', async () => {
|
||||
const { viewers, adapter, poses } = await mountedMapillary();
|
||||
await Promise.all([adapter.open('a'), adapter.open('b')]);
|
||||
assert.deepEqual(viewers.moves, ['b']);
|
||||
assert.deepEqual(
|
||||
poses.map((pose) => pose.imageId),
|
||||
['b'],
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user