mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Fixes from gekh's re-review of 061d269, each with a regression test that
fails on the old code:
- A pan that drops a tile still loading now settles the loading count
(the aborted request no longer owns its key, so its `finally` never
did), and purges the previous zoom's tiles once nothing is pending.
LOADING no longer sticks on the status pill and chip.
- Zoom 0 (above 15,000 km) is a zoom: `zoom == null` instead of `!zoom`.
- A pose that arrives after a photo was closed is dropped, in the
MapillaryJS adapter and in the viewer host, so the marker and sequence
selection do not come back.
- Ground casting passes the tile's signal to the terrain request, so a
cancelled tile stops its height batch instead of holding the queue.
- The server's in-memory tile cache honours the 24 h TTL like the disk.
- The viewer's one-time resize runs for every photo, not just the first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
394 lines
13 KiB
JavaScript
394 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { promises as fsp } from 'node:fs';
|
||
import path from 'node:path';
|
||
import test from 'node:test';
|
||
import { PbfWriter } from 'pbf';
|
||
import { mapillaryProxy } from 'gods-eye-view/server/providers/mapillary';
|
||
import {
|
||
listTileLayers,
|
||
stripTileLayers,
|
||
} from '../../server/providers/mapillary/trim.js';
|
||
import {
|
||
fetchTile,
|
||
normalizeTileAddress,
|
||
TileRequestError,
|
||
_resetTileMemoryForTest,
|
||
} from '../../server/providers/mapillary/tiles.js';
|
||
|
||
/** Mount the plugin and return a caller keyed by route. */
|
||
function install(plugin, mode = 'configureServer') {
|
||
const routes = new Map();
|
||
plugin[mode]({
|
||
middlewares: {
|
||
use(route, handler) {
|
||
routes.set(route, handler);
|
||
},
|
||
},
|
||
});
|
||
const call = async (route, url = '/', method = 'GET', body) => {
|
||
const handler = routes.get(route);
|
||
assert.ok(handler, `route ${route} is mounted`);
|
||
const headers = {};
|
||
const res = {
|
||
statusCode: 200,
|
||
headersSent: false,
|
||
writableEnded: false,
|
||
setHeader(name, value) {
|
||
headers[name.toLowerCase()] = value;
|
||
},
|
||
getHeader(name) {
|
||
return headers[name.toLowerCase()];
|
||
},
|
||
writeHead(status, extra) {
|
||
this.statusCode = status;
|
||
Object.assign(headers, extra || {});
|
||
},
|
||
end(payload) {
|
||
this.body = payload;
|
||
this.writableEnded = true;
|
||
},
|
||
on() {},
|
||
};
|
||
const listeners = {};
|
||
const req = {
|
||
url,
|
||
method,
|
||
headers: {},
|
||
on(event, fn) {
|
||
listeners[event] = fn;
|
||
},
|
||
[Symbol.asyncIterator]: async function* () {
|
||
if (body) yield Buffer.from(body);
|
||
},
|
||
};
|
||
await handler(req, res);
|
||
return { ...res, headers };
|
||
};
|
||
return { routes, call };
|
||
}
|
||
|
||
const json = (res) => JSON.parse(String(res.body));
|
||
|
||
test('the plugin mounts the status and tile routes for dev and preview servers', () => {
|
||
for (const mode of ['configureServer', 'configurePreviewServer']) {
|
||
const { routes } = install(mapillaryProxy(), mode);
|
||
assert.deepEqual([...routes.keys()].sort(), [
|
||
'/api/mapillary/status',
|
||
'/api/mapillary/tiles',
|
||
]);
|
||
}
|
||
});
|
||
|
||
test('status reports whether a token exists, never its value, and rejects non-GET', async () => {
|
||
const saved = { MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN };
|
||
delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||
try {
|
||
const { call } = install(mapillaryProxy());
|
||
const res = await call('/api/mapillary/status');
|
||
assert.equal(res.statusCode, 200);
|
||
assert.deepEqual(json(res), { configured: false });
|
||
assert.doesNotMatch(String(res.body), /MLY\||planner/);
|
||
const post = await call('/api/mapillary/status', '/', 'POST');
|
||
assert.equal(post.statusCode, 405);
|
||
} finally {
|
||
for (const [key, value] of Object.entries(saved))
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
}
|
||
});
|
||
|
||
test('tile route validates the path and refuses to proxy without a token', async () => {
|
||
const saved = process.env.MAPILLARY_CLIENT_TOKEN;
|
||
delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||
try {
|
||
const { call } = install(mapillaryProxy());
|
||
const bad = await call('/api/mapillary/tiles', '/coverage/14/1/x');
|
||
assert.equal(bad.statusCode, 400);
|
||
const signs = await call('/api/mapillary/tiles', '/signs/14/1/2');
|
||
assert.equal(signs.statusCode, 400, 'only coverage tiles are proxied');
|
||
const noKey = await call('/api/mapillary/tiles', '/coverage/14/1/2');
|
||
assert.equal(noKey.statusCode, 503);
|
||
assert.deepEqual(json(noKey), { error: 'no_key', keyRequired: true });
|
||
const post = await call('/api/mapillary/tiles', '/coverage/14/1/2', 'POST');
|
||
assert.equal(post.statusCode, 405);
|
||
} finally {
|
||
if (saved === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||
else process.env.MAPILLARY_CLIENT_TOKEN = saved;
|
||
}
|
||
});
|
||
|
||
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
||
assert.equal(
|
||
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
||
'coverage/3/1/2',
|
||
);
|
||
assert.deepEqual(
|
||
normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' })
|
||
.dropLayers,
|
||
['image'],
|
||
);
|
||
for (const bad of [
|
||
{ layer: 'image', z: 14, x: 1, y: 1 },
|
||
{ layer: 'points', z: 14, x: 1, y: 1 },
|
||
{ layer: 'signs', z: 14, x: 1, y: 1 },
|
||
{ layer: 'coverage', z: 15, x: 1, y: 1 },
|
||
{ layer: 'coverage', z: 2, x: 4, y: 0 },
|
||
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
|
||
{ layer: 'coverage', z: -1, x: 0, y: 0 },
|
||
])
|
||
assert.throws(
|
||
() => normalizeTileAddress(bad),
|
||
TileRequestError,
|
||
JSON.stringify(bad),
|
||
);
|
||
});
|
||
|
||
test('fetchTile serves from memory after one upstream fetch and strips the image layer', async () => {
|
||
const savedFetch = globalThis.fetch;
|
||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token';
|
||
_resetTileMemoryForTest();
|
||
const tile = (() => {
|
||
const writer = new PbfWriter();
|
||
for (const name of ['sequence', 'image']) {
|
||
writer.writeMessage(
|
||
3,
|
||
(layer, pbf) => {
|
||
pbf.writeVarintField(15, 2);
|
||
pbf.writeStringField(1, layer.name);
|
||
if (layer.name === 'image')
|
||
pbf.writeBytesField(4, Buffer.alloc(4000, 1));
|
||
},
|
||
{ name },
|
||
);
|
||
}
|
||
return Buffer.from(writer.finish());
|
||
})();
|
||
let upstreamCalls = 0;
|
||
globalThis.fetch = async (url) => {
|
||
upstreamCalls++;
|
||
assert.match(
|
||
String(url),
|
||
/tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/10\/0\/0\?access_token=/,
|
||
);
|
||
return new Response(tile, {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/x-protobuf' },
|
||
});
|
||
};
|
||
try {
|
||
// z10 is never requested by the app (overview z0–5, sequences z11–14), and
|
||
// the file is removed first so an earlier test run cannot leave a disk hit.
|
||
await fsp
|
||
.rm(
|
||
path.join(
|
||
process.cwd(),
|
||
'.gev-cache/mapillary/tiles/coverage/10/0-0.pbf',
|
||
),
|
||
)
|
||
.catch(() => {});
|
||
const first = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||
assert.equal(first.source, 'upstream');
|
||
assert.deepEqual(listTileLayers(first.bytes), ['sequence']);
|
||
assert.ok(first.bytes.length < 100, 'image layer stripped in transit');
|
||
const second = await fetchTile({ layer: 'coverage', z: 10, x: 0, y: 0 });
|
||
assert.equal(second.source, 'memory');
|
||
assert.equal(upstreamCalls, 1);
|
||
} finally {
|
||
globalThis.fetch = savedFetch;
|
||
if (savedToken === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||
else process.env.MAPILLARY_CLIENT_TOKEN = savedToken;
|
||
_resetTileMemoryForTest();
|
||
}
|
||
});
|
||
|
||
test('a tile held in memory past the 24 h TTL is fetched again', async (t) => {
|
||
const tile = { layer: 'coverage', z: 9, x: 3, y: 3 };
|
||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||
const first = fetchTile(tile);
|
||
await settle();
|
||
upstream[0].release();
|
||
assert.equal((await first).source, 'upstream');
|
||
assert.equal((await fetchTile(tile)).source, 'memory');
|
||
const now = Date.now();
|
||
t.mock.method(Date, 'now', () => now + 25 * 60 * 60 * 1000);
|
||
const again = fetchTile(tile);
|
||
await settle();
|
||
assert.equal(upstream.length, 2, 'neither cache answers a stale tile');
|
||
upstream[1].release();
|
||
assert.equal((await again).source, 'upstream');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A deferred upstream for in-flight tests: every fetch waits until released,
|
||
* and records whether its signal was aborted. Coordinates use z9, which the
|
||
* app never requests, and their disk files are removed first.
|
||
*/
|
||
async function withDeferredUpstream(tiles, run) {
|
||
const savedFetch = globalThis.fetch;
|
||
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
|
||
process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token';
|
||
_resetTileMemoryForTest();
|
||
for (const { z, x, y } of tiles)
|
||
await fsp
|
||
.rm(
|
||
path.join(
|
||
process.cwd(),
|
||
`.gev-cache/mapillary/tiles/coverage/${z}/${x}-${y}.pbf`,
|
||
),
|
||
)
|
||
.catch(() => {});
|
||
const body = (() => {
|
||
const writer = new PbfWriter();
|
||
writer.writeMessage(
|
||
3,
|
||
(layer, pbf) => {
|
||
pbf.writeVarintField(15, 2);
|
||
pbf.writeStringField(1, layer.name);
|
||
},
|
||
{ name: 'sequence' },
|
||
);
|
||
return Buffer.from(writer.finish());
|
||
})();
|
||
const upstream = [];
|
||
globalThis.fetch = (url, { signal } = {}) =>
|
||
new Promise((resolve, reject) => {
|
||
const call = { url: String(url), signal, aborted: false };
|
||
call.release = () =>
|
||
resolve(
|
||
new Response(body, {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/x-protobuf' },
|
||
}),
|
||
);
|
||
signal?.addEventListener(
|
||
'abort',
|
||
() => {
|
||
call.aborted = true;
|
||
reject(signal.reason);
|
||
},
|
||
{ once: true },
|
||
);
|
||
upstream.push(call);
|
||
});
|
||
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||
try {
|
||
await run({ upstream, settle, body });
|
||
} finally {
|
||
globalThis.fetch = savedFetch;
|
||
if (savedToken === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN;
|
||
else process.env.MAPILLARY_CLIENT_TOKEN = savedToken;
|
||
_resetTileMemoryForTest();
|
||
}
|
||
}
|
||
|
||
test('a joined tile request survives the first caller abandoning it', async () => {
|
||
const tile = { layer: 'coverage', z: 9, x: 1, y: 1 };
|
||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||
const first = new AbortController();
|
||
const second = new AbortController();
|
||
const a = fetchTile(tile, { signal: first.signal });
|
||
await settle();
|
||
const b = fetchTile(tile, { signal: second.signal });
|
||
await settle();
|
||
assert.equal(upstream.length, 1, 'one upstream request for both');
|
||
first.abort();
|
||
await assert.rejects(a, { name: 'AbortError' });
|
||
assert.equal(upstream[0].aborted, false, 'the shared fetch keeps going');
|
||
upstream[0].release();
|
||
const joined = await b;
|
||
assert.equal(joined.source, 'inflight');
|
||
assert.deepEqual(listTileLayers(joined.bytes), ['sequence']);
|
||
assert.equal(upstream.length, 1);
|
||
});
|
||
});
|
||
|
||
test('a joined caller that aborts leaves at once and the first still gets the tile', async () => {
|
||
const tile = { layer: 'coverage', z: 9, x: 2, y: 2 };
|
||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||
const first = new AbortController();
|
||
const second = new AbortController();
|
||
const a = fetchTile(tile, { signal: first.signal });
|
||
await settle();
|
||
const b = fetchTile(tile, { signal: second.signal });
|
||
await settle();
|
||
second.abort();
|
||
await assert.rejects(b, { name: 'AbortError' });
|
||
assert.equal(upstream[0].aborted, false);
|
||
upstream[0].release();
|
||
assert.equal((await a).source, 'upstream');
|
||
});
|
||
});
|
||
|
||
test('the upstream tile fetch is cancelled once every waiter has left', async () => {
|
||
const tile = { layer: 'coverage', z: 9, x: 3, y: 3 };
|
||
await withDeferredUpstream([tile], async ({ upstream, settle }) => {
|
||
const first = new AbortController();
|
||
const second = new AbortController();
|
||
const a = fetchTile(tile, { signal: first.signal });
|
||
await settle();
|
||
const b = fetchTile(tile, { signal: second.signal });
|
||
await settle();
|
||
first.abort();
|
||
await assert.rejects(a, { name: 'AbortError' });
|
||
assert.equal(upstream[0].aborted, false, 'one waiter is still there');
|
||
second.abort();
|
||
await assert.rejects(b, { name: 'AbortError' });
|
||
assert.equal(upstream[0].aborted, true, 'the last waiter cancels it');
|
||
// A later request for the same tile starts a fresh fetch.
|
||
const again = fetchTile(tile);
|
||
await settle();
|
||
assert.equal(upstream.length, 2);
|
||
upstream[1].release();
|
||
assert.equal((await again).source, 'upstream');
|
||
});
|
||
});
|
||
|
||
// ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ──
|
||
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
|
||
function tile(layers) {
|
||
const writer = new PbfWriter();
|
||
for (const { name, payload } of layers) {
|
||
writer.writeMessage(
|
||
3,
|
||
(layer, pbf) => {
|
||
pbf.writeVarintField(15, 2); // version
|
||
pbf.writeStringField(1, layer.name);
|
||
pbf.writeMessage(2, (_f, p) => p.writeVarintField(1, 7), null); // feature
|
||
pbf.writeVarintField(5, 4096); // extent
|
||
if (layer.payload) pbf.writeBytesField(4, layer.payload); // a big key
|
||
},
|
||
{ name, payload },
|
||
);
|
||
}
|
||
return Buffer.from(writer.finish());
|
||
}
|
||
|
||
test('dropping a layer keeps the others byte-for-byte', () => {
|
||
const big = Buffer.alloc(50_000, 7);
|
||
const bytes = tile([
|
||
{ name: 'sequence' },
|
||
{ name: 'image', payload: big },
|
||
{ name: 'overview' },
|
||
]);
|
||
const trimmed = stripTileLayers(bytes, ['image']);
|
||
assert.deepEqual(listTileLayers(trimmed), ['sequence', 'overview']);
|
||
assert.ok(trimmed.length < 200, `trimmed to ${trimmed.length} bytes`);
|
||
assert.deepEqual(trimmed, tile([{ name: 'sequence' }, { name: 'overview' }]));
|
||
});
|
||
|
||
test('a tile without the layer is returned untouched', () => {
|
||
const bytes = tile([{ name: 'sequence' }]);
|
||
assert.equal(stripTileLayers(bytes, ['image']), bytes);
|
||
assert.equal(stripTileLayers(bytes, []), bytes);
|
||
assert.equal(stripTileLayers(Buffer.alloc(0), ['image']).length, 0);
|
||
});
|
||
|
||
test('layer names are read without decoding features', () => {
|
||
assert.deepEqual(listTileLayers(tile([{ name: 'a' }, { name: 'b' }])), [
|
||
'a',
|
||
'b',
|
||
]);
|
||
});
|