diff --git a/CHANGELOG.md b/CHANGELOG.md index dea4c3eb6..4b4a751ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Street Level loads the coverage you are looking at in tilted views. Tiles are ranked along the line of sight, from the ground under the camera to the ground at the centre of the screen, and screen rays meet the ground at its real height (Denver is 1,600 m up), so the streets in front of the camera are no longer dropped for tiles near the horizon. On Google 3D, lines that are placed on the bare earth no longer fall back to draping over roofs when their finer points cross grid cells the coarse ones skipped. - Street Level names a Mapillary token that Mapillary refuses: the panel reads KEY REJECTED and says to replace `MAPILLARY_CLIENT_TOKEN` in Provider Settings, instead of a raw "HTTP 403". The proxy stops asking Mapillary on every camera move after a refusal (for that token) or a rate limit (until its `Retry-After`), and the panel clears the message when the layer is switched off. +- Street Level polish from review: whole-earth coverage dots on the far side of the globe no longer show through it; a 360° or flat filter keeps a dense tile's matching lines instead of losing them to the per-tile cap; lines that lost their ground heights are cast again rather than left draped; a selection made while lines rebuild is applied once they are ready; hovering picks at most every 120 ms; Esc shrinks the expanded viewer wherever focus is, and closing a photo leaves focus in the panel; switching the layer off and on during the first photo load no longer leaves the viewer stuck. Portable panels lift only after a real mouse or pen drag, never on a click or a touch swipe. The MapillaryJS stylesheet now loads with the viewer instead of on every page (about 85 KB less render-blocking CSS). The tile proxy's disk cache is swept of expired tiles and capped at 1 GiB, keeps a cached tile's real age in memory, and refuses zoom levels the app never requests. + - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js index 2b7c5ee9d..c1e2c1e32 100644 --- a/server/providers/mapillary/constants.js +++ b/server/providers/mapillary/constants.js @@ -6,12 +6,13 @@ export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; /** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ export const TILE_LAYERS = Object.freeze({ // Overview points (z0–5), sequences (z6–14) and image points (z14 only). + // The app asks for overview z0–5 and sequences z11–14 only (see + // src/layers/streetLevel/tileMath.js); region-sized z6–10 tiles are refused. // The z14 `image` point layer is ~98% of a 10 MB tile and unused here: // image positions come from the graph API per sequence. Dropped in transit. coverage: Object.freeze({ upstream: 'mly1_public', - minZoom: 0, - maxZoom: 14, + zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]), dropLayers: Object.freeze(['image']), }), }); @@ -31,6 +32,14 @@ export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); */ export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; +/** + * Disk cache bound. A tile write starts a background sweep at most this often: + * it deletes files past TILE_DISK_TTL_MS, then the oldest files until the + * cache fits in TILE_DISK_MAX_BYTES (thousands of trimmed street-level tiles). + */ +export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000; +export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024; + /** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ export const TILE_MAX_BYTES = 48 * 1024 * 1024; diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 80c9e113a..6da9896db 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -6,6 +6,8 @@ import { TILE_LAYERS, TILE_DISK_DIR, TILE_DISK_TTL_MS, + TILE_DISK_SWEEP_INTERVAL_MS, + TILE_DISK_MAX_BYTES, TILE_MAX_BYTES, TILE_MEMORY_BUDGET_BYTES, TILE_FETCH_TIMEOUT_MS, @@ -72,22 +74,23 @@ function heldRefusal(token) { /** * Validate and normalize a tile address. Layer names are the proxy's public - * ids (coverage/points/signs); zoom must be inside the layer's published range. + * ids (coverage only); zoom must be inside one of the layer's zoom ranges. * @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}} */ export function normalizeTileAddress({ layer, z, x, y }) { - const spec = TILE_LAYERS[layer]; + const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null; if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`); const zi = Number(z); const xi = Number(x); const yi = Number(y); if ( ![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) || - zi < spec.minZoom || - zi > spec.maxZoom + !spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max) ) throw new TileRequestError( - `Tile zoom for ${layer} must be ${spec.minZoom}–${spec.maxZoom}`, + `Tile zoom for ${layer} must be ${spec.zoomRanges + .map(([min, max]) => `${min}–${max}`) + .join(' or ')}`, ); const n = 2 ** zi; if (xi >= n || yi >= n) @@ -130,11 +133,12 @@ function memoryGet(key) { return hit.bytes; } -function memoryPut(key, bytes) { +/** `at` is when Mapillary served the bytes (a disk tile's mtime). */ +function memoryPut(key, bytes, at = Date.now()) { if (bytes.length > TILE_MEMORY_BUDGET_BYTES / 2) return; const existing = _memory.get(key); if (existing) _memoryBytes -= existing.bytes.length; - _memory.set(key, { bytes, at: Date.now() }); + _memory.set(key, { bytes, at }); _memoryBytes += bytes.length; while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) { const [oldest, entry] = _memory.entries().next().value; @@ -143,27 +147,41 @@ function memoryPut(key, bytes) { } } -function diskPath({ layer, z, x, y }) { - return path.join(TILE_DISK_DIR, layer, String(z), `${x}-${y}.pbf`); +/** Disk cache root; tests point it at a temporary directory. */ +let _diskDir = TILE_DISK_DIR; + +function diskPath({ layer, z, x, y }, root = _diskDir) { + return path.join(root, layer, String(z), `${x}-${y}.pbf`); } +/** Disk-cache read; the tile's age comes from the file's mtime. */ async function readDisk(address) { const file = diskPath(address); try { const stat = await fsp.stat(file); if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null; - return await fsp.readFile(file); + return { bytes: await fsp.readFile(file), at: stat.mtimeMs }; } catch { return null; } } -function writeDisk(address, bytes) { - const file = diskPath(address); +/** + * Write a tile in the background. `at` dates a rewrite of an existing tile + * with its original fetch time, so the rewrite does not extend its life. + */ +function writeDisk(address, bytes, at = null) { + // The sweep after it covers this write's cache root, even if it has moved. + const root = _diskDir; + const file = diskPath(address, root); fsp .mkdir(path.dirname(file), { recursive: true }) .then(() => fsp.writeFile(`${file}.tmp`, bytes)) + .then( + () => at != null && fsp.utimes(`${file}.tmp`, new Date(), new Date(at)), + ) .then(() => fsp.rename(`${file}.tmp`, file)) + .then(() => scheduleSweep(root)) .catch((error) => console.warn( '[Mapillary Proxy] tile cache write failed:', @@ -172,6 +190,73 @@ function writeDisk(address, bytes) { ); } +let _lastSweepAt = 0; +let _sweeping = null; +let _sweepWarned = false; + +/** Start a background sweep of `root` unless one ran within the interval. */ +function scheduleSweep(root) { + if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS) + return; + _lastSweepAt = Date.now(); + _sweeping = sweepTileDisk({ root }) + .catch((error) => { + if (_sweepWarned) return; + _sweepWarned = true; + console.warn( + '[Mapillary Proxy] tile cache sweep failed:', + error?.message || error, + ); + }) + .finally(() => { + _sweeping = null; + }); +} + +/** Every file under `dir` with its size and mtime; a missing dir is empty. */ +async function listCacheFiles(dir) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + const files = []; + for (const entry of entries) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listCacheFiles(file))); + else if (entry.isFile()) { + // A file renamed or swept away meanwhile is simply skipped. + const stat = await fsp.stat(file).catch(() => null); + if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs }); + } + } + return files; +} + +/** + * Bound the disk cache: delete tiles past TILE_DISK_TTL_MS, then the oldest + * until the rest fit in `maxBytes`. Runs in the background after tile writes. + * @returns {Promise<{removed: number, bytes: number}>} files deleted, bytes kept + */ +export async function sweepTileDisk({ + root = _diskDir, + maxBytes = TILE_DISK_MAX_BYTES, +} = {}) { + const now = Date.now(); + const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at); + let bytes = files.reduce((sum, { size }) => sum + size, 0); + let removed = 0; + for (const { file, size, at } of files) { + if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break; + await fsp.rm(file, { force: true }); + bytes -= size; + removed++; + } + return { removed, bytes }; +} + async function fetchUpstream(address, signal) { const token = mapillaryToken(); if (!token) throw new TileRequestError('Mapillary token not configured', 503); @@ -241,10 +326,11 @@ export async function fetchTile(request, { signal } = {}) { if (memory) return { bytes: memory, source: 'memory', address }; const disk = await readDisk(address); if (disk) { - // Older cache files may still hold the untrimmed tile: trim and replace. - const bytes = trim(address, disk); - if (bytes !== disk) writeDisk(address, bytes); - memoryPut(address.key, bytes); + // Older cache files may still hold the untrimmed tile: trim and replace, + // keeping the original fetch time so the tile still expires on schedule. + const bytes = trim(address, disk.bytes); + if (bytes !== disk.bytes) writeDisk(address, bytes, disk.at); + memoryPut(address.key, bytes, disk.at); return { bytes, source: 'disk', address }; } let flight = _inFlight.get(address.key); @@ -314,3 +400,9 @@ export function _resetTileMemoryForTest() { _inFlight.clear(); _upstreamHold = null; } + +/** Test seam: keep the disk cache in `dir` (null restores the default). */ +export function _setTileCacheDirForTest(dir) { + _diskDir = dir || TILE_DISK_DIR; + _lastSweepAt = 0; +} diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 3abcc38cb..f1f4ef797 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -1,20 +1,57 @@ import assert from 'node:assert/strict'; import { promises as fsp } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import test from 'node:test'; +import test, { after } 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 * as tiles from '../../server/providers/mapillary/tiles.js'; import { fetchTile, normalizeTileAddress, TileRequestError, _resetTileMemoryForTest, + _setTileCacheDirForTest, } from '../../server/providers/mapillary/tiles.js'; +// Every tile these tests cache lands in a throwaway directory, never in the +// developer's .gev-cache. +const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-')); +_setTileCacheDirForTest(cacheDir); +// The directory is not switched back: a background write still landing must +// never sweep the real cache. +after(() => fsp.rm(cacheDir, { recursive: true, force: true })); +const tileFile = ({ z, x, y }, root = cacheDir) => + path.join(root, 'coverage', String(z), `${x}-${y}.pbf`); +const HOUR = 60 * 60 * 1000; + +/** Write a cache file dated `ageMs` ago. */ +async function writeAged(file, bytes, ageMs) { + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, bytes); + const at = new Date(Date.now() - ageMs); + await fsp.utimes(file, at, at); +} + +/** Poll until `check()` holds (background cache writes are not awaited). */ +async function waitFor(check, what) { + for (let i = 0; i < 200; i++) { + if (await check()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`timed out waiting for ${what}`); +} + +const exists = (file) => + fsp.stat(file).then( + () => true, + () => false, + ); + /** Mount the plugin and return a caller keyed by route. */ function install(plugin, mode = 'configureServer') { const routes = new Map(); @@ -119,8 +156,8 @@ test('tile route validates the path and refuses to proxy without a token', async /** * Drive the tile route against a scripted upstream: `answer(call)` returns the - * Response for each upstream fetch. Coordinates use z9 (never requested by - * the app) and refusals are never cached, so no disk tile is written. + * Response for each upstream fetch. Refusals are never cached, so no disk tile + * is written. */ async function withUpstream(answer, run) { const savedFetch = globalThis.fetch; @@ -147,7 +184,7 @@ for (const status of [401, 403]) await withUpstream( () => new Response('{}', { status }), async ({ calls, call }) => { - for (const tile of ['/coverage/9/5/5', '/coverage/9/6/6']) { + for (const tile of ['/coverage/14/5/5', '/coverage/14/6/6']) { const res = await call('/api/mapillary/tiles', tile); assert.equal(res.statusCode, 403); assert.deepEqual(json(res), { @@ -159,7 +196,7 @@ for (const status of [401, 403]) assert.equal(calls.length, 1, 'the second miss is answered locally'); // A new token is asked at once. process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|new|token'; - await call('/api/mapillary/tiles', '/coverage/9/7/7'); + await call('/api/mapillary/tiles', '/coverage/14/7/7'); assert.equal(calls.length, 2); }, ); @@ -169,19 +206,19 @@ test('a 429 passes its Retry-After on and holds misses until it is over (review await withUpstream( () => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }), async ({ calls, call }) => { - const first = await call('/api/mapillary/tiles', '/coverage/9/5/5'); + const first = await call('/api/mapillary/tiles', '/coverage/14/5/5'); assert.equal(first.statusCode, 429); assert.equal(first.headers['retry-after'], '30'); assert.deepEqual(json(first), { error: 'Mapillary is rate-limiting tile requests', retryAfter: 30, }); - const held = await call('/api/mapillary/tiles', '/coverage/9/6/6'); + const held = await call('/api/mapillary/tiles', '/coverage/14/6/6'); assert.equal(held.statusCode, 429); assert.equal(calls.length, 1, 'held: Mapillary is not asked again'); const now = Date.now(); t.mock.method(Date, 'now', () => now + 31_000); - await call('/api/mapillary/tiles', '/coverage/9/6/6'); + await call('/api/mapillary/tiles', '/coverage/14/6/6'); assert.equal(calls.length, 2, 'asked again once the wait is over'); }, ); @@ -213,6 +250,40 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', ( ); }); +test('normalizeTileAddress refuses prototype keys as layer names', () => { + for (const layer of ['constructor', '__proto__', 'toString']) + assert.throws( + () => normalizeTileAddress({ layer, z: 1, x: 0, y: 0 }), + TileRequestError, + layer, + ); +}); + +test('coverage zooms the app never requests (z6–10) are refused with a 400 (review P2)', async () => { + for (const z of [6, 7, 8, 9, 10]) + assert.throws( + () => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }), + TileRequestError, + `z${z}`, + ); + for (const z of [0, 5, 11, 14]) + assert.equal( + normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z, + z, + ); + await withUpstream( + () => new Response(null, { status: 204 }), + async ({ calls, call }) => { + const res = await call('/api/mapillary/tiles', '/coverage/9/5/5'); + assert.equal(res.statusCode, 400); + assert.deepEqual(json(res), { + error: 'Tile zoom for coverage must be 0–5 or 11–14', + }); + assert.equal(calls.length, 0, 'Mapillary is not asked'); + }, + ); +}); + 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; @@ -239,7 +310,7 @@ test('fetchTile serves from memory after one upstream fetch and strips the image upstreamCalls++; assert.match( String(url), - /tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/10\/0\/0\?access_token=/, + /tiles\.mapillary\.com\/maps\/vtp\/mly1_public\/2\/14\/0\/0\?access_token=/, ); return new Response(tile, { status: 200, @@ -247,21 +318,13 @@ test('fetchTile serves from memory after one upstream fetch and strips the image }); }; 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 }); + // The file is removed first so an earlier test cannot leave a disk hit. + await fsp.rm(tileFile({ z: 14, x: 0, y: 0 }), { force: true }); + const first = await fetchTile({ layer: 'coverage', z: 14, 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 }); + const second = await fetchTile({ layer: 'coverage', z: 14, x: 0, y: 0 }); assert.equal(second.source, 'memory'); assert.equal(upstreamCalls, 1); } finally { @@ -273,7 +336,7 @@ test('fetchTile serves from memory after one upstream fetch and strips the image }); 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 }; + const tile = { layer: 'coverage', z: 14, x: 3, y: 3 }; await withDeferredUpstream([tile], async ({ upstream, settle }) => { const first = fetchTile(tile); await settle(); @@ -290,25 +353,116 @@ test('a tile held in memory past the 24 h TTL is fetched again', async (t) => { }); }); +test('a disk tile keeps its age in memory and through a retrim rewrite (review P2)', async (t) => { + const address = { layer: 'coverage', z: 14, x: 8, y: 8 }; + const file = tileFile(address); + const untrimmed = tile([ + { name: 'sequence' }, + { name: 'image', payload: Buffer.alloc(4000, 1) }, + ]); + await writeAged(file, untrimmed, 23 * HOUR); + const writtenAt = (await fsp.stat(file)).mtimeMs; + await withUpstream( + () => new Response(tile([{ name: 'sequence' }]), { status: 200 }), + async ({ calls }) => { + const first = await fetchTile(address); + assert.equal(first.source, 'disk'); + assert.deepEqual(listTileLayers(first.bytes), ['sequence']); + // The untrimmed file is replaced by the trimmed tile, still dated when + // Mapillary served it. + await waitFor( + async () => (await fsp.stat(file)).size === first.bytes.length, + 'the trimmed rewrite', + ); + assert.ok( + Math.abs((await fsp.stat(file)).mtimeMs - writtenAt) < 1000, + 'the rewrite keeps the original mtime', + ); + const now = Date.now(); + t.mock.method(Date, 'now', () => now + 2 * HOUR); + const later = await fetchTile(address); + assert.equal(later.source, 'upstream', '25 h old: no cache serves it'); + assert.equal(calls.length, 1); + // Let the refetched tile land before the next test moves the cache. + await waitFor( + async () => (await fsp.stat(file)).mtimeMs > writtenAt + HOUR, + 'the refetched tile write', + ); + }, + ); +}); + +/** Point the disk cache at a fresh directory for one test. */ +async function withCacheDir(run) { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-sweep-')); + tiles._setTileCacheDirForTest(dir); + try { + await run(dir); + } finally { + tiles._setTileCacheDirForTest(cacheDir); + await fsp.rm(dir, { recursive: true, force: true }); + } +} + +test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', async () => { + await withCacheDir(async (dir) => { + const expired = tileFile({ z: 14, x: 1, y: 1 }, dir); + const fresh = tileFile({ z: 14, x: 2, y: 2 }, dir); + await writeAged(expired, Buffer.alloc(100, 1), 25 * HOUR); + await writeAged(fresh, Buffer.alloc(100, 2), 23 * HOUR); + const result = await tiles.sweepTileDisk(); + assert.equal(result.removed, 1); + assert.equal(await exists(expired), false); + assert.equal(await exists(fresh), true); + }); +}); + +test('the disk sweep evicts the oldest tiles past the size cap (review P2)', async () => { + await withCacheDir(async (dir) => { + const files = [3, 2, 1].map((age) => { + const file = tileFile({ z: 13, x: age, y: age }, dir); + return { file, age }; + }); + for (const { file, age } of files) + await writeAged(file, Buffer.alloc(100, age), age * HOUR); + const result = await tiles.sweepTileDisk({ maxBytes: 250 }); + assert.deepEqual(result, { removed: 1, bytes: 200 }); + assert.equal(await exists(files[0].file), false, 'the oldest goes first'); + assert.equal(await exists(files[1].file), true); + assert.equal(await exists(files[2].file), true); + }); +}); + +test('caching a tile sweeps expired files off the disk in the background (review P2)', async () => { + await withCacheDir(async (dir) => { + const expired = tileFile({ z: 12, x: 1, y: 1 }, dir); + const fresh = tileFile({ z: 12, x: 2, y: 2 }, dir); + await writeAged(expired, Buffer.alloc(100, 1), 30 * HOUR); + await writeAged(fresh, Buffer.alloc(100, 2), HOUR); + await withUpstream( + () => new Response(tile([{ name: 'sequence' }]), { status: 200 }), + async () => { + const address = { layer: 'coverage', z: 14, x: 4, y: 4 }; + assert.equal((await fetchTile(address)).source, 'upstream'); + await waitFor(() => exists(tileFile(address, dir)), 'the tile write'); + await waitFor(async () => !(await exists(expired)), 'the sweep'); + assert.equal(await exists(fresh), true); + }, + ); + }); +}); + /** * 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. + * and records whether its signal was aborted. 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(() => {}); + for (const tile of tiles) await fsp.rm(tileFile(tile), { force: true }); const body = (() => { const writer = new PbfWriter(); writer.writeMessage( @@ -354,7 +508,7 @@ async function withDeferredUpstream(tiles, run) { } test('a joined tile request survives the first caller abandoning it', async () => { - const tile = { layer: 'coverage', z: 9, x: 1, y: 1 }; + const tile = { layer: 'coverage', z: 14, x: 1, y: 1 }; await withDeferredUpstream([tile], async ({ upstream, settle }) => { const first = new AbortController(); const second = new AbortController(); @@ -375,7 +529,7 @@ test('a joined tile request survives the first caller abandoning it', async () = }); 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 }; + const tile = { layer: 'coverage', z: 14, x: 2, y: 2 }; await withDeferredUpstream([tile], async ({ upstream, settle }) => { const first = new AbortController(); const second = new AbortController(); @@ -392,7 +546,7 @@ test('a joined caller that aborts leaves at once and the first still gets the ti }); test('the upstream tile fetch is cancelled once every waiter has left', async () => { - const tile = { layer: 'coverage', z: 9, x: 3, y: 3 }; + const tile = { layer: 'coverage', z: 14, x: 3, y: 3 }; await withDeferredUpstream([tile], async ({ upstream, settle }) => { const first = new AbortController(); const second = new AbortController();