refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory

- No pointer cursor over coverage lines (no other layer has one); a click
  test replaces the hover tests.
- No legend: one source, one colour.
- The on-globe credit lives in index.js; credits.js is gone.
- Billboard glyphs are SVG data URLs with the same geometry, so tests need
  no canvas stub.
- The tile cache keeps the 120 most recently used tiles, same 24 h TTL,
  instead of a byte budget; listTileLayers was test-only.
- providers/mapillary/ is flattened into the layer and its policy merged
  into one policy.js; the source's method list is a literal in the catalog.
- One fakeCesiumViewer for the layer tests.
- The fixture gate keeps the steps the unit tests do not cover: coverage and
  credit, a rejected key, line -> cone -> photo, close, a keyless page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-08 19:17:45 +04:00
1 parent 34d43c7e3f
commit e214129e45
41 files changed
+335 -909

No files matched your search

+12 -25
View File
@@ -5,19 +5,15 @@ import {
mapillaryProxy,
TILE_ROUTE_MAX_PER_MIN,
} from 'gods-eye-view/server/providers/mapillary';
import {
listTileLayers,
stripTileLayers,
} from '../../server/providers/mapillary/trim.js';
import { stripTileLayers } from '../../server/providers/mapillary/trim.js';
import {
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_MEMORY_MAX_TILES,
TILE_UPSTREAM_CONCURRENCY,
TILE_TTL_MS,
fetchTile,
parseTilePath,
_resetTileCacheForTest,
_tileMemoryForTest,
} from '../../server/providers/mapillary/tiles.js';
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
@@ -545,26 +541,18 @@ test('a tile held in memory past its TTL is fetched again', async (t) => {
);
});
test('the memory cache evicts the least recently used tile past its budget', async () => {
const MB = 1024 * 1024;
// Three 30 MB tiles fit in the budget, a fourth does not.
const big = tile([{ name: 'sequence', payload: Buffer.alloc(30 * MB) }]);
assert.ok(3 * big.length < TILE_MEMORY_BUDGET_BYTES);
assert.ok(4 * big.length > TILE_MEMORY_BUDGET_BYTES);
test('the memory cache evicts the least recently used tile past its size', async () => {
await withUpstream(
() => ok(big),
async ({ calls, call }) => {
() => ok(),
async ({ calls }) => {
const get = async (x) =>
(await call('/api/mapillary/tiles', `/coverage/14/${x}/20`)).headers[
'x-gev-cache'
];
for (const x of [20, 21, 22]) assert.equal(await get(x), 'upstream');
assert.equal(await get(20), 'memory', 'A is used again');
assert.equal(await get(23), 'upstream');
assert.equal(_tileMemoryForTest().entries, 3);
for (const x of [20, 22, 23]) assert.equal(await get(x), 'memory');
assert.equal(await get(21), 'upstream', 'B was evicted');
assert.equal(calls.length, 5);
(await fetchTile(parseTilePath(`/coverage/14/${x}/20`))).source;
for (let x = 0; x < TILE_MEMORY_MAX_TILES; x++) await get(x);
assert.equal(await get(0), 'memory', 'the first is used again');
await get(TILE_MEMORY_MAX_TILES);
assert.equal(await get(0), 'memory', 'so it stays');
assert.equal(await get(1), 'upstream', 'the least recently used went');
assert.equal(calls.length, TILE_MEMORY_MAX_TILES + 2);
},
);
});
@@ -578,7 +566,6 @@ test('dropping a layer keeps the others byte-for-byte', () => {
{ name: 'overview' },
]);
const trimmed = stripTileLayers(bytes, ['image']);
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
});
-91
View File
@@ -1,89 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fixtureTile } from '../../scripts/qa-street-level.mjs';
import {
answerMapillaryRequest,
describeCall,
PHOTO_LINE,
PHOTO_SEQUENCE_ID,
photoImages,
THUMB_HOST,
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
import { redact } from '../../scripts/qa-browserEvidence.mjs';
test('fixture tiles give the hermetic gate something real to filter', async () => {
const { decodeCoverageTile } =
await import('../layers/streetLevel/providers/mapillary/decode.js');
const now = Date.UTC(2026, 9, 1);
const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), {
x: 2662,
y: 6286,
z: 14,
});
const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID);
assert.equal(grid.length, 8);
const pano = grid.filter((s) => s.isPano).length;
assert.ok(pano > 0 && pano < 8, '360° and flat both present');
const year = 365 * 86_400_000;
const old = grid.filter((s) => now - s.capturedAt > year).length;
assert.ok(old > 0 && old < 8, 'recent and older both present');
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
});
/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */
test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => {
const { decodeCoverageTile } =
await import('../layers/streetLevel/providers/mapillary/decode.js');
const { lonToTileX, latToTileY } =
await import('../layers/streetLevel/tileMath.js');
const x = lonToTileX(PHOTO_LINE.lon, 14);
const rows = new Set(
[PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)),
);
assert.ok(rows.size > 1, 'the line crosses a tile edge');
let south = Infinity;
let north = -Infinity;
for (const y of rows) {
const line = decodeCoverageTile(fixtureTile(14, x, y), {
x,
y,
z: 14,
}).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID);
assert.ok(line, `tile 14/${x}/${y} carries the photo line`);
for (const [lon, lat] of line.parts[0]) {
assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4);
south = Math.min(south, lat);
north = Math.max(north, lat);
}
}
assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4);
assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4);
// Far away there is no photo line, only the grid.
const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), {
x: 100,
y: 100,
z: 14,
});
assert.equal(
elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID),
false,
);
});
test('the fixture photos: one sequence, 360° and flat, spaced past the cone thinning', () => {
const images = photoImages(Date.UTC(2026, 9, 1));
assert.ok(images.length >= 3);
assert.ok(images.some((image) => image.isPano));
assert.ok(images.some((image) => !image.isPano));
for (let i = 1; i < images.length; i++) {
const gap = (images[i].lat - images[i - 1].lat) * 110_540;
assert.ok(gap > 3, `gap ${gap}`);
}
assert.equal(new Set(images.map((image) => image.id)).size, images.length);
});
const graph = (path, params) =>
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
const answer = (url, method = 'GET') => {
@@ -94,22 +19,6 @@ const answer = (url, method = 'GET') => {
};
};
test('the Graph fixtures answer the provider’s sequence lookups', () => {
const images = photoImages();
const sequence = answer(
graph('images', {
sequence_ids: PHOTO_SEQUENCE_ID,
fields: 'id,geometry,compass_angle,captured_at,is_pano',
limit: '2000',
}),
);
assert.equal(sequence.json.data.length, images.length);
assert.deepEqual(
answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data,
[],
);
});
test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => {
const [first, second] = photoImages();
const spatial = answer(