Files
gods-eye-view/src/layers/streetLevel/providers/mapillary/sequences.test.mjs
T
German KhokhlovandClaude Opus 5.5 37a96072fa refactor(street-level): one provider, no orbit overview, no prewarm, simpler tile choice
The layer now owns its Mapillary parts directly: the provider registry,
pick router, multi-provider UI summary, params codec helper and provider
context are gone. createStreetLevelLayer({ source }) builds coverage,
sequences, the nearest-image lookup and the MapillaryJS viewer itself; a
single providerOn switch keeps the share link's `mapillary` option (off
draws nothing), and getParams/setParams keep { mapillary, pano, sinceDays }.

No z0-5 overview points: above 60 km nothing is drawn and the panel says to
zoom in; the proxy now serves only coverage z11-14. Tiles come from the
visible ground within range of the camera, ranked from the ground under it
(no line-of-sight ranking or near-range padding). MapillaryJS loads on the
first photo; viewer prewarm is removed. Key status, a rejected key and a
rate limit share one `blocked` state on coverage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:19:24 +04:00

225 lines
7.2 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getSequenceImages(sequenceId, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ sequenceId, signal, resolve, reject });
});
},
/** The latest lookup for a sequence. */
last(sequenceId) {
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
},
};
}
/** Graph image records for a sequence, a few metres apart. */
function records(sequenceId, count = 2) {
return Array.from({ length: count }, (_, i) => ({
id: `${sequenceId}-${i}`,
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
compass_angle: 90,
captured_at: 1_700_000_000_000 + i,
}));
}
function setup() {
const source = deferredSource();
const colours = [];
const errors = [];
const state = {
services: {},
viewer: {},
filter: { pano: 'all', sinceDays: 0 },
notify() {},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: cones(),
loading: false,
abort: null,
},
};
const parts = {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
reportSequenceError: (message) => errors.push(message),
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, colours, errors, drawn };
}
test('a failed sequence load leaves no stale cones and can be retried', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['A-0', 'A-1']);
sequences.select('B');
assert.deepEqual(drawn(), [], "A's cones go while B loads");
source.last('B').reject(new Error('Sequence images unavailable'));
await settle();
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
assert.deepEqual(colours.at(-1), ['B', false]);
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
// Clicking B again asks again.
sequences.select('B');
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
source.last('B').resolve(records('B'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
});
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
sequences.select('B');
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
source.last('B').resolve(records('B'));
await settle();
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.cache.has('A'), false);
assert.equal(state.sequence.loading, false);
});
test('a second click on a loading sequence does not start another lookup', async () => {
const { source, sequences } = setup();
sequences.select('A');
sequences.select('A');
assert.equal(source.calls.length, 1);
assert.equal(source.calls[0].signal.aborted, false);
});
test('a cached sequence is re-selected without a lookup', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
sequences.select('B');
source.last('B').resolve(records('B'));
await settle();
sequences.select('A');
assert.equal(source.calls.length, 2, 'A came from the cache');
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, 'A');
});
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
sequences.clearSelection();
assert.equal(source.last('A').signal.aborted, true);
assert.deepEqual(colours, [
['A', true],
['A', false],
]);
assert.equal(state.sequence.selectedId, null);
assert.equal(state.sequence.loading, false);
assert.equal(errors.at(-1), null, 'an error about it goes too');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
test('cones are clamped to the ground and turned to their compass angle', async () => {
const { state, source, sequences } = setup();
sequences.select('A');
source.last('A').resolve(records('A', 2));
await settle();
const items = state.sequence.collection.items;
assert.equal(items.length, 2);
for (const cone of items) {
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
}
});
test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A');
// One image under the camera, one near its antipode.
source.last('A').resolve([
...records('A', 1),
{
id: 'A-far',
geometry: { coordinates: [58.51, -38.58] },
captured_at: 1_800_000_000_000,
},
]);
await settle();
const [near, far] = state.sequence.collection.items;
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});