mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
646 lines
22 KiB
JavaScript
646 lines
22 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import * as Cesium from 'cesium';
|
||
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
|
||
import { freshCoverage } from '../../state.js';
|
||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||
import {
|
||
COLORS,
|
||
COVERAGE_MAX_SEQUENCES,
|
||
COVERAGE_MAX_TILES,
|
||
} from './policy.js';
|
||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||
|
||
const RAD = Math.PI / 180;
|
||
// Node has no WebGL context to report line-width limits; a browser's
|
||
// context sets these on startup, and appearances validate against them.
|
||
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
|
||
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
|
||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
/** A scene collection that remembers what is on the globe. */
|
||
function collection() {
|
||
const items = new Set();
|
||
return {
|
||
items,
|
||
add(primitive) {
|
||
items.add(primitive);
|
||
return primitive;
|
||
},
|
||
remove(primitive) {
|
||
return items.delete(primitive);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** The centre of the z14 tile holding a point, so a small view is one tile. */
|
||
function tileCentre(lon, lat) {
|
||
const x = lonToTileX(lon, 14);
|
||
const y = latToTileY(lat, 14);
|
||
const { west, east, south, north } = tileBounds(x, y, 14);
|
||
return {
|
||
tile: { x, y, z: 14 },
|
||
lon: (west + east) / 2,
|
||
lat: (south + north) / 2,
|
||
};
|
||
}
|
||
|
||
/** A viewer looking straight down on a 0.004° square around `view`. */
|
||
function fakeViewer(view) {
|
||
const postRender = new Set();
|
||
const preRender = new Set();
|
||
return {
|
||
view,
|
||
postRender,
|
||
preRender,
|
||
scene: {
|
||
canvas: { clientWidth: 100, clientHeight: 100 },
|
||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||
groundPrimitives: collection(),
|
||
primitives: collection(),
|
||
postRender: {
|
||
addEventListener(listener) {
|
||
postRender.add(listener);
|
||
return () => postRender.delete(listener);
|
||
},
|
||
},
|
||
preRender: {
|
||
addEventListener(listener) {
|
||
preRender.add(listener);
|
||
return () => preRender.delete(listener);
|
||
},
|
||
},
|
||
},
|
||
camera: {
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** A source whose tile requests the test resolves, one call at a time. */
|
||
function deferredSource() {
|
||
const calls = [];
|
||
return {
|
||
calls,
|
||
getTile(layer, z, x, y, { signal } = {}) {
|
||
return new Promise((resolve, reject) => {
|
||
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
function setup() {
|
||
const centre = tileCentre(-121.4944, 38.5816);
|
||
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
||
const source = deferredSource();
|
||
const state = {
|
||
viewer,
|
||
services: {},
|
||
enabled: true,
|
||
providerOn: true,
|
||
filter: { pano: 'all', sinceDays: 0 },
|
||
notify() {},
|
||
coverage: {
|
||
...freshCoverage(),
|
||
// The key status is in; skip Cesium's one-time terrain table download.
|
||
blocked: null,
|
||
terrainReady: Promise.resolve(),
|
||
},
|
||
sequence: { selectedId: null },
|
||
};
|
||
const coverage = createCoverage({ state, source });
|
||
const bytes = encodeCoverageTile(centre.tile, {
|
||
sequences: [
|
||
{
|
||
id: 'seq-1',
|
||
parts: [
|
||
[
|
||
[centre.lon - 0.001, centre.lat],
|
||
[centre.lon + 0.001, centre.lat],
|
||
],
|
||
],
|
||
},
|
||
],
|
||
});
|
||
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
|
||
const onGlobe = () =>
|
||
viewer.scene.groundPrimitives.items.size +
|
||
viewer.scene.primitives.items.size;
|
||
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
|
||
}
|
||
|
||
/** Run one frame's listeners, as Cesium's render loop would. */
|
||
function frame(listeners) {
|
||
for (const listener of [...listeners]) listener();
|
||
}
|
||
|
||
test('a superseded tile request cannot strand lines on the globe', async () => {
|
||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||
|
||
coverage.refresh(); // street zoom: request #1 for the tile
|
||
assert.equal(forTile().length, 1);
|
||
viewer.view.height = 100_000; // zoom out: clear() drops request #1
|
||
coverage.refresh();
|
||
viewer.view.height = 900; // back in before #1 settles: request #2
|
||
coverage.refresh();
|
||
assert.equal(forTile().length, 2);
|
||
await settle();
|
||
|
||
// The superseded request finishes late. It must not settle request #2's
|
||
// key, or the next refresh would start a duplicate load.
|
||
forTile()[0].resolve(bytes);
|
||
await settle();
|
||
assert.equal(
|
||
state.coverage.pending.has(tileKey),
|
||
true,
|
||
'#2 still owns the key',
|
||
);
|
||
assert.equal(state.coverage.pending.size, 1, 'still loading');
|
||
coverage.refresh();
|
||
assert.equal(forTile().length, 2, 'no duplicate request');
|
||
|
||
forTile()[1].resolve(bytes);
|
||
await settle();
|
||
assert.equal(state.coverage.tiles.size, 1);
|
||
assert.ok(onGlobe() > 0, 'the tile drew its lines');
|
||
|
||
// Everything drawn can be removed: nothing is orphaned.
|
||
coverage.clear();
|
||
assert.equal(onGlobe(), 0);
|
||
});
|
||
|
||
test('sequence lines are draped on the globe and on 3D tiles alike', async () => {
|
||
const { viewer, source, coverage, bytes } = setup();
|
||
coverage.refresh();
|
||
source.calls[0].resolve(bytes);
|
||
await settle();
|
||
const { groundPrimitives, primitives } = viewer.scene;
|
||
assert.equal(primitives.items.size, 0);
|
||
assert.equal(groundPrimitives.items.size, 1);
|
||
const [line] = groundPrimitives.items;
|
||
assert.ok(line instanceof Cesium.GroundPolylinePrimitive);
|
||
assert.equal(line.classificationType, Cesium.ClassificationType.BOTH);
|
||
coverage.clear();
|
||
assert.equal(groundPrimitives.items.size, 0);
|
||
});
|
||
|
||
test('panning off a tile that is still loading stops counting it as loading', async () => {
|
||
const { viewer, source, state, coverage } = setup();
|
||
coverage.refresh();
|
||
assert.equal(state.coverage.pending.size, 1);
|
||
// Same zoom, two tiles east: the first request is dropped, a new one starts.
|
||
viewer.view.lon += 0.05;
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 2);
|
||
assert.equal(source.calls[0].signal.aborted, true);
|
||
assert.equal(
|
||
state.coverage.pending.size,
|
||
1,
|
||
'only the live request counts as loading',
|
||
);
|
||
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
|
||
source.calls[1].resolve(new Uint8Array());
|
||
await settle();
|
||
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
|
||
coverage.clear();
|
||
});
|
||
|
||
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
|
||
const { viewer, source, state, coverage, bytes } = setup();
|
||
coverage.refresh();
|
||
source.calls[0].resolve(bytes);
|
||
await settle();
|
||
assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines');
|
||
viewer.view.height = 20_000_000;
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 1, 'no tile is asked for');
|
||
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn');
|
||
assert.equal(state.coverage.zoom, null);
|
||
assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
|
||
viewer.view.height = 900;
|
||
coverage.refresh();
|
||
assert.equal(coverage.stats().hint, '', 'back at street zoom');
|
||
assert.equal(source.calls.length, 2);
|
||
coverage.clear();
|
||
});
|
||
|
||
test('the key status gates coverage: nothing before it answers, nothing without a key', () => {
|
||
for (const configured of [false, true]) {
|
||
const { source, state, coverage } = setup();
|
||
state.coverage.blocked = 'status';
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 0, 'nothing before the status answers');
|
||
coverage.setKeyStatus(configured);
|
||
if (configured)
|
||
assert.equal(source.calls.length, 1, 'coverage loads once it is known');
|
||
else {
|
||
assert.equal(source.calls.length, 0, 'a key-less install never asks');
|
||
assert.equal(coverage.stats().keyRequired, true);
|
||
assert.equal(coverage.stats().keyRejected, false);
|
||
coverage.unblock();
|
||
assert.equal(
|
||
coverage.stats().keyRequired,
|
||
true,
|
||
'kept: it is no refusal',
|
||
);
|
||
}
|
||
coverage.clear();
|
||
}
|
||
});
|
||
|
||
test('a rejected key stops coverage requests until the layer goes off', async () => {
|
||
const { viewer, source, state, coverage } = setup();
|
||
coverage.refresh();
|
||
const rejected = Object.assign(
|
||
new Error('Mapillary rejected the access token'),
|
||
{
|
||
keyRejected: true,
|
||
},
|
||
);
|
||
source.calls[0].reject(rejected);
|
||
await settle();
|
||
assert.equal(state.coverage.blocked, 'rejected');
|
||
assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key');
|
||
assert.equal(coverage.stats().keyRejected, true);
|
||
assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||
viewer.view.lon += 0.05;
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
|
||
// Turning the layer off forgets the verdict; the next run asks again.
|
||
coverage.clear();
|
||
coverage.unblock();
|
||
assert.equal(coverage.stats().keyRejected, false);
|
||
assert.equal(coverage.stats().error, null);
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 2);
|
||
coverage.clear();
|
||
});
|
||
|
||
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
|
||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||
const { viewer, source, state, 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();
|
||
assert.match(coverage.stats().error, /rate-limited/);
|
||
assert.equal(coverage.stats().keyRequired, false);
|
||
viewer.view.lon += 0.05;
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 1, 'held: nothing is requested');
|
||
t.mock.timers.tick(30_000);
|
||
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
|
||
assert.equal(coverage.stats().error, null);
|
||
coverage.clear();
|
||
coverage.unblock();
|
||
});
|
||
|
||
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
|
||
const { state, source, coverage, centre } = setup();
|
||
const line = (n) => [
|
||
[
|
||
[centre.lon - 0.001, centre.lat + n * 1e-6],
|
||
[centre.lon + 0.001, centre.lat + n * 1e-6],
|
||
],
|
||
];
|
||
// 700 newer flat sequences, then 300 older 360° ones.
|
||
const sequences = [];
|
||
for (let i = 0; i < 700; i++)
|
||
sequences.push({
|
||
id: `flat-${i}`,
|
||
parts: line(i),
|
||
capturedAt: 2_000_000_000_000 + i,
|
||
});
|
||
for (let i = 0; i < 300; i++)
|
||
sequences.push({
|
||
id: `pano-${i}`,
|
||
parts: line(i),
|
||
capturedAt: 1_000_000_000_000 + i,
|
||
isPano: true,
|
||
});
|
||
state.filter = { pano: 'pano', sinceDays: 0 };
|
||
coverage.refresh();
|
||
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
||
await settle();
|
||
const [entry] = state.coverage.tiles.values();
|
||
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
|
||
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
||
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
|
||
// Picking and recolouring look sequences up among the drawn lines.
|
||
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
||
assert.equal(entry.sequences.has('flat-699'), false);
|
||
state.filter = { pano: 'all', sinceDays: 0 };
|
||
coverage.rebuild();
|
||
assert.equal(entry.count, cap, 'all imagery is capped as before');
|
||
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
|
||
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
|
||
coverage.clear();
|
||
});
|
||
|
||
/** The colour attribute value of a line, selected or not. */
|
||
function colourValue(selected) {
|
||
return Array.from(
|
||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||
selected
|
||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||
),
|
||
);
|
||
}
|
||
|
||
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
|
||
function controlled(record, ready) {
|
||
const control = { ready, attributes: { color: undefined } };
|
||
Object.defineProperty(record.primitive, 'ready', {
|
||
get: () => control.ready,
|
||
});
|
||
record.primitive.getGeometryInstanceAttributes = (id) =>
|
||
id === 'mly:seq:seq-1' ? control.attributes : undefined;
|
||
return control;
|
||
}
|
||
|
||
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
|
||
const { viewer, source, state, coverage, bytes } = setup();
|
||
coverage.refresh();
|
||
source.calls[0].resolve(bytes);
|
||
await settle();
|
||
const [entry] = state.coverage.tiles.values();
|
||
// Selected while its line is still building: nothing to recolour yet...
|
||
const first = controlled(entry.primitives[0], false);
|
||
state.sequence.selectedId = 'seq-1';
|
||
coverage.recolorSequence('seq-1', true);
|
||
assert.equal(first.attributes.color, undefined);
|
||
// ...so the highlight lands once it is ready.
|
||
first.ready = true;
|
||
frame(viewer.postRender);
|
||
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
|
||
|
||
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
|
||
coverage.rebuild();
|
||
const second = controlled(entry.primitives[0], false);
|
||
coverage.recolorSequence('seq-1', false);
|
||
state.sequence.selectedId = null;
|
||
second.ready = true;
|
||
frame(viewer.postRender);
|
||
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
|
||
coverage.clear();
|
||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||
});
|
||
|
||
test('a sequence that crosses a tile edge is counted once', async () => {
|
||
const { viewer, source, coverage, centre } = setup();
|
||
// Look straight down on the edge between this tile and its east neighbour.
|
||
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||
viewer.view.lon = east;
|
||
coverage.refresh();
|
||
assert.equal(source.calls.length, 2, 'two tiles in view');
|
||
const across = [
|
||
[
|
||
[east - 0.001, centre.lat],
|
||
[east + 0.001, centre.lat],
|
||
],
|
||
];
|
||
for (const call of source.calls) {
|
||
const [, x, y] = call.key.split('/').map(Number);
|
||
call.resolve(
|
||
encodeCoverageTile(
|
||
{ x, y, z: 14 },
|
||
{
|
||
sequences: [
|
||
{ id: 'across', parts: across },
|
||
{ id: `only-${x}`, parts: across },
|
||
],
|
||
},
|
||
),
|
||
);
|
||
}
|
||
await settle();
|
||
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
|
||
coverage.clear();
|
||
});
|
||
|
||
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
|
||
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||
return rayCamera({
|
||
lon,
|
||
lat,
|
||
altitude: height,
|
||
heading,
|
||
pitch,
|
||
width: 100,
|
||
height: 100,
|
||
fovX: 60,
|
||
fovY: 40,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
|
||
* at ground `aheadM` metres out; the globe terrain knows the ground.
|
||
*/
|
||
function denverStreetView(aheadM) {
|
||
const z = 14;
|
||
const x = lonToTileX(-104.99, z);
|
||
const y = latToTileY(39.74, z);
|
||
const tile = tileBounds(x, y, z);
|
||
const lon = (tile.west + tile.east) / 2;
|
||
const lat = (tile.south + tile.north) / 2;
|
||
const ground = 1600;
|
||
const above = 300;
|
||
const context = setup();
|
||
context.viewer.scene.globe = {
|
||
show: true,
|
||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||
getHeight: () => ground,
|
||
};
|
||
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
|
||
context.viewer.camera = tiltedCamera({
|
||
lon,
|
||
lat,
|
||
height: ground + above,
|
||
heading: 90,
|
||
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
|
||
});
|
||
context.coverage.refresh();
|
||
const keys = context.source.calls.map((call) => call.key);
|
||
/** Requested tiles as [dx, dy] from the tile under the camera. */
|
||
const offsets = keys.map((k) => {
|
||
const [, tx, ty] = k.split('/').map(Number);
|
||
return [tx - x, ty - y];
|
||
});
|
||
return {
|
||
...context,
|
||
keys,
|
||
offsets,
|
||
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
|
||
};
|
||
}
|
||
|
||
test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => {
|
||
// The screen centre meets the ground at the centre of the next tile east.
|
||
const { state, coverage, keys, offsets, key } = denverStreetView(
|
||
(tileWidthM) => tileWidthM,
|
||
);
|
||
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
|
||
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
||
assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at');
|
||
for (const [dx, dy] of offsets)
|
||
assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`);
|
||
coverage.clear();
|
||
});
|
||
|
||
test('a street view toward the horizon asks only for the ground within range', () => {
|
||
// The screen centre meets the ground 6 km out, past the 3 km range.
|
||
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
|
||
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
||
for (const [dx, dy] of offsets)
|
||
assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`);
|
||
coverage.clear();
|
||
});
|
||
|
||
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
|
||
const { viewer, source, state, coverage, bytes } = setup();
|
||
coverage.refresh();
|
||
source.calls[0].resolve(bytes);
|
||
await settle();
|
||
const [entry] = state.coverage.tiles.values();
|
||
const line = controlled(entry.primitives[0], true);
|
||
state.sequence.selectedId = 'seq-1';
|
||
coverage.recolorSequence('seq-1', true);
|
||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
||
viewer.view.height = 3000;
|
||
coverage.refresh();
|
||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
||
// ...and the selection is cleared meanwhile.
|
||
state.sequence.selectedId = null;
|
||
coverage.recolorSequence('seq-1', false);
|
||
assert.deepEqual(
|
||
Array.from(line.attributes.color),
|
||
colourValue(false),
|
||
'the retained line is not left cyan',
|
||
);
|
||
coverage.clear();
|
||
});
|
||
|
||
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
|
||
const { viewer, source, state, coverage, bytes } = setup();
|
||
coverage.refresh();
|
||
source.calls[0].resolve(bytes);
|
||
await settle();
|
||
const [entry] = state.coverage.tiles.values();
|
||
const line = controlled(entry.primitives[0], false);
|
||
viewer.view.height = 3000;
|
||
coverage.refresh();
|
||
assert.equal(state.coverage.stale.size, 1);
|
||
frame(viewer.postRender);
|
||
state.sequence.selectedId = 'seq-1';
|
||
coverage.recolorSequence('seq-1', true);
|
||
line.ready = true;
|
||
frame(viewer.postRender);
|
||
assert.deepEqual(
|
||
line.attributes.color && Array.from(line.attributes.color),
|
||
colourValue(true),
|
||
'the highlight lands on the retained line',
|
||
);
|
||
coverage.clear();
|
||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||
});
|
||
|
||
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', 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.unblock();
|
||
});
|
||
|
||
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
|
||
const { source, state, coverage, tileKey } = setup();
|
||
let terrainLoaded;
|
||
state.coverage.terrainReady = new Promise((resolve) => {
|
||
terrainLoaded = resolve;
|
||
});
|
||
const unhandled = [];
|
||
const onUnhandled = (reason) => unhandled.push(reason);
|
||
process.on('unhandledRejection', onUnhandled);
|
||
try {
|
||
coverage.refresh();
|
||
source.calls
|
||
.find((call) => call.key === tileKey)
|
||
.reject(new Error('Mapillary tiles HTTP 500'));
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
|
||
assert.deepEqual(unhandled, []);
|
||
} finally {
|
||
terrainLoaded();
|
||
await settle();
|
||
process.off('unhandledRejection', onUnhandled);
|
||
}
|
||
});
|