diff options
Diffstat (limited to 'viewer/tests/tilelayer.test.mjs')
| -rw-r--r-- | viewer/tests/tilelayer.test.mjs | 402 |
1 files changed, 402 insertions, 0 deletions
diff --git a/viewer/tests/tilelayer.test.mjs b/viewer/tests/tilelayer.test.mjs new file mode 100644 index 0000000..10e2159 --- /dev/null +++ b/viewer/tests/tilelayer.test.mjs @@ -0,0 +1,402 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const THREE = await import('three'); +const { TileLayer, CONCURRENCY } = await import('../js/tilelayer.js'); +const { GlobeView } = await import('../js/globe.js'); +const { tileOf, tileKey, tilesForPoints } = await import('../js/tiles.js'); + +function setup() { + const view = new GlobeView(fakeRenderer(), () => {}), pending = []; + const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); + const layer = new TileLayer(view, { url: '/tiles/v1/{layer}/{z}/{x}/{y}.jpg', load }); + layer.setLayers('relief', ''); + return { layer, pending }; +} +const grid = (lat, lon, d) => Array.from({ length: 49 }, (_, k) => ({ lat: lat + (Math.floor(k / 7) - 3) * d, lon: lon + ((k % 7) - 3) * d })); +const flush = () => new Promise(r => setTimeout(r, 0)); +async function settle(pending, rounds = 6) { + for (let i = 0; i < rounds; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); } +} + +test('off below the minimum zoom; loading, capped, when zoomed in', () => { + const { layer, pending } = setup(); + layer.update(50, grid(10, 20, 1)); + assert.equal(layer.active, false); + assert.equal(pending.length, 0); + layer.update(0.5, grid(10, 20, 0.05)); + assert.equal(layer.active, true); + assert.equal(layer.z, 9); + assert.equal(pending.length, CONCURRENCY); + assert.match(pending[0].url, /^\/tiles\/v1\/relief\/9\/\d+\/\d+\.jpg$/); +}); + +test('moving away drops queued tiles that are no longer wanted', () => { + const { layer, pending } = setup(); + layer.update(0.5, grid(10, 20, 0.05)); + assert.ok(layer.queue.length > 0); + const pts = grid(-40, -100, 0.05); + layer.update(0.5, pts); + const want = new Set(tilesForPoints(pts, 9, 1).map(tileKey)); + assert.ok(layer.queue.every(e => want.has(e.key))); + assert.equal(pending.length, CONCURRENCY); // nothing beyond the cap started +}); + +test('loaded tiles show; an ancestor stands in while children load', async () => { + const { layer, pending } = setup(); + const here = [{ lat: 10, lon: 20 }]; + layer.update(1.0, here); // z 8 + await settle(pending); + layer.update(1.0, here); + const parent = layer.tiles.get(tileKey(tileOf(10, 20, 8))); + assert.equal(parent.state, 'ready'); + assert.equal(parent.mesh.visible, true); + layer.update(0.5, here); // z 9: children still loading + assert.equal(layer.z, 9); + assert.equal(parent.mesh.visible, true); + await settle(pending); + layer.update(0.5, here); + assert.equal(parent.mesh.visible, false); + assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 9))).mesh.visible, true); +}); + +test('switching layers drops every tile', async () => { + const { layer, pending } = setup(); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + await settle(pending); + layer.setLayers('biomes', ''); + assert.equal(layer.tiles.size, 0); + assert.equal(layer.group.children.length, 0); +}); + +test('coverage before sharpness: too many tiles for the budget → one zoom coarser', () => { + const { layer, pending } = setup(); + const pts = Array.from({ length: 900 }, (_, k) => ({ lat: 10 + (Math.floor(k / 30) - 15) * 0.1, lon: 20 + ((k % 30) - 15) * 0.1 })); + layer.update(0.5, pts, 24); // z 9 would need far more than 24 tiles + assert.ok(layer.z < 9, `z ${layer.z}`); + assert.ok(layer.queue.length + layer.inflight <= 24); + assert.equal(pending.length, CONCURRENCY); +}); + +test('suspend (view hidden) stops loading and frees every tile', async () => { + const { layer, pending } = setup(); + layer.update(0.5, grid(10, 20, 0.05)); + assert.ok(layer.queue.length > 0); + layer.suspend(); + assert.equal(layer.queue.length, 0); + assert.equal(layer.tiles.size, 0); + const started = pending.length; + pending.splice(0).forEach(p => p.res()); + await flush(); + await flush(); + assert.equal(layer.group.children.length, 0, 'late loads are dropped'); + assert.equal(pending.length, 0, `nothing new started (had ${started})`); +}); + +test('with an over-layer a tile costs two requests: still at most 6 in flight', () => { + const { layer, pending } = setup(); + layer.setLayers('relief', 'biomes'); + layer.update(0.5, grid(10, 20, 0.05)); + assert.equal(pending.length, CONCURRENCY); + assert.equal(layer.inflight, CONCURRENCY); +}); + +test('failed tiles are retried after a backoff', async () => { + let clock = 0; + const view = new GlobeView(fakeRenderer(), () => {}), pending = []; + const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock }); + layer.setLayers('relief', ''); + const here = [{ lat: 10, lon: 20 }], key = tileKey(tileOf(10, 20, 8)), url = `/t/relief/${key}.jpg`; + layer.update(1.0, here); + for (let i = 0; i < 3; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); } + assert.equal(layer.tiles.get(key).state, 'error'); + layer.update(1.0, here); + assert.ok(!pending.some(p => p.url === url), 'not yet'); + clock += 60000; + layer.update(1.0, here); + assert.ok(pending.some(p => p.url === url), 'retried after the backoff'); +}); + +test('the queue follows the current view, its centre tile first', () => { + const { layer } = setup(); + layer.update(0.5, grid(10, 20, 0.05)); + layer.update(0.5, [{ lat: 11.5, lon: 21.5 }, ...grid(10, 20, 0.05)]); + assert.equal(layer.queue[0].key, tileKey(tileOf(11.5, 21.5, 9))); +}); + +test('a failed tile wakes the map when its backoff ends (no input needed)', async () => { + const view = new GlobeView(fakeRenderer(), () => {}), pending = [], timers = []; + let changes = 0; + const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, onChange: () => changes++, + later: (fn, ms) => timers.push({ fn, ms }) }); + layer.setLayers('relief', ''); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + pending.splice(0).forEach(p => p.rej(new Error('503'))); + await flush(); await flush(); + assert.equal(timers.length, CONCURRENCY); + assert.equal(timers[0].ms, 2000); + const before = changes; + timers[0].fn(); + assert.equal(changes, before + 1, 'redraw requested → update() retries'); + layer.setLayers('biomes', ''); + const after = changes; + timers[1].fn(); + assert.equal(changes, after, 'stale wake-ups (older generation) do nothing'); +}); + +test('a throw while building a tile marks it failed, frees its textures, rejects nothing', async () => { + const view = new GlobeView(fakeRenderer(), () => {}), pending = [], unhandled = []; + const onRej = e => unhandled.push(e); + process.on('unhandledRejection', onRej); + const texes = []; + const load = url => new Promise(res => pending.push({ url, res: () => { const t = new THREE.Texture(); t.dispose = () => { t.gone = true; }; texes.push(t); res(t); } })); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, later: () => {} }); + layer.setLayers('relief', ''); + Object.defineProperty(view, 'shade', { get() { throw new Error('boom'); } }); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + for (let i = 0; i < 6; i++) { pending.splice(0).forEach(p => p.res()); await flush(); await flush(); } + process.off('unhandledRejection', onRej); + assert.deepEqual(unhandled, []); + const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))); + assert.equal(e.state, 'error'); + assert.ok(texes.every(t => t.gone)); + assert.equal(layer.inflight, 0); +}); + +function terrainSetup(meshes = () => Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) })) { + const view = new GlobeView(fakeRenderer(), () => {}), pending = [], urls = []; + const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); + const loadMesh = url => { urls.push(url); return meshes(url); }; + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load, loadMesh }); + layer.setLayers('relief', ''); + return { layer, pending, urls, view }; +} +const radius = e => { const a = e.mesh.geometry.attributes.position.array, c = e.mesh.position; + return Math.hypot(a[0] + c.x, a[1] + c.y, a[2] + c.z); }; + +test('terrain: each tile also loads its heights and is displaced by height × exaggeration', async () => { + const { R_KM } = await import('../js/geo.js'); + const { layer, pending, urls } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; + layer.setTerrain(1); + layer.update(1.0, here); + assert.equal(pending.length, CONCURRENCY / 2, 'heights (≈ 66 KB) count as a full request'); + await settle(pending); + const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))); + assert.equal(e.state, 'ready'); + assert.ok(urls.includes(`/t/mesh/${e.key}.bin`)); + assert.ok(Math.abs(radius(e) - (1 + 1000 / (R_KM * 1000))) < 1e-7); + assert.ok(Math.abs(layer.heightAt(10, 20) - 1000) < 1e-9); +}); + +test('exaggeration reshapes in place; switching 3D on or off reloads every tile', async () => { + const { R_KM } = await import('../js/geo.js'); + const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; + layer.setTerrain(1); + layer.update(1.0, here); + await settle(pending); + const e = layer.tiles.get(tileKey(tileOf(10, 20, 8))), mesh = e.mesh; + layer.setTerrain(2.5); + assert.equal(e.mesh, mesh); + assert.ok(Math.abs(radius(e) - (1 + 2500 / (R_KM * 1000))) < 1e-7); + layer.setTerrain(0); + assert.equal(layer.tiles.size, 0); + assert.equal(layer.heightAt(10, 20), null); +}); + +test('a bad heights file fails the tile (retry later), never throws', async () => { + const { layer, pending } = terrainSetup(() => Promise.reject(new Error('404'))); + layer.setTerrain(1); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + await settle(pending); + assert.equal(layer.tiles.get(tileKey(tileOf(10, 20, 8))).state, 'error'); +}); + +test('flat views never go 3D', async () => { + const { FlatView } = await import('../js/flatmap.js'); + const layer = new TileLayer(new FlatView(fakeRenderer(), 'equal_earth', () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/m' }); + layer.setTerrain(2); + assert.equal(layer.terrain, 0); +}); + +test('tilted: near samples load fine tiles, far samples coarse ones; budget still drops everything a level', () => { + const { layer } = setup(); + const near = grid(10, 20, 0.002).map(p => ({ ...p, km: 0.05 })), far = grid(10.5, 20, 0.05).map(p => ({ ...p, km: 2 })); + layer.update(1, [...near, ...far]); + const zs = new Set(layer.queue.map(e => e.t.z)); + assert.equal(layer.z, Math.max(...zs)); + assert.ok(zs.size >= 2 && Math.min(...zs) <= 8, [...zs].join()); + layer.update(1, [...near, ...far], 4); + assert.ok(layer.queue.length + layer.inflight <= 4 + CONCURRENCY); + assert.ok(layer.z < 13); +}); + +test('heightAt(…, shown) reads only drawn tiles; heightGen bumps when the drawn heights change', async () => { + const { layer, pending } = terrainSetup(), here = [{ lat: 10, lon: 20 }]; + layer.setTerrain(1); + layer.update(1.0, here); // z 8 + await settle(pending); + layer.update(1.0, here); + const g0 = layer.heightGen; + assert.ok(g0 > 0); + assert.equal(layer.heightAt(10, 20, true), 1000); + layer.update(0.25, here); // z 10 wanted: the z 8 tile still stands in, drawn + assert.equal(layer.heightAt(10, 20, true), 1000); + await settle(pending); + layer.update(0.25, here); // children drawn, the z 8 tile hidden (still cached) + assert.ok(layer.heightGen > g0); + const e8 = layer.tiles.get(tileKey(tileOf(10, 20, 8))); + assert.equal(e8.mesh.visible, false); + layer.update(50, here); // zoomed out: nothing drawn + assert.equal(layer.heightAt(10, 20, true), null); + assert.equal(layer.heightAt(10, 20), 1000, 'the camera floor still uses every loaded height'); +}); + +test('3D: a stand-in ancestor never overlaps its loaded descendants (coarse ground would poke through)', async () => { + const { parentTile } = await import('../js/tiles.js'); + const failing = tileKey(tileOf(10, 20, 10)); + const { layer, pending } = terrainSetup(url => (url.includes(`/${failing}.bin`) ? Promise.reject(new Error('500')) + : Promise.resolve({ h: new Float32Array(129 * 129).fill(1000), water: new Uint8Array(129 * 129) }))); + layer.setTerrain(1); + const here = [{ lat: 10, lon: 20 }]; + layer.update(1.0, here); // z 8 loads + await settle(pending); + layer.update(0.25, here); // z 10: one tile keeps failing + await settle(pending); + layer.update(0.25, here); + const shown = [...layer.tiles.values()].filter(e => e.mesh?.visible).map(e => e.t); + const keys = new Set(shown.map(tileKey)); + assert.ok(shown.some(t => t.z < 10), 'an ancestor stands in for the failing tile'); + for (const t of shown) for (let a = parentTile(t); a.z >= 5; a = parentTile(a)) assert.ok(!keys.has(tileKey(a)), `${tileKey(t)} under ${tileKey(a)}`); +}); + +test('a failed tile still in view keeps its place (and its backoff) when the cache trims', async () => { + const view = new GlobeView(fakeRenderer(), () => {}), pending = []; + const load = url => new Promise((res, rej) => pending.push({ url, res: () => res(new THREE.Texture()), rej })); + let clock = 0; + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load, now: () => clock++, later: () => {} }); + layer.setLayers('relief', ''); + const here = { lat: 10, lon: 20 }, key = tileKey(tileOf(10, 20, 9)), url = `/t/relief/${key}.jpg`; + layer.update(0.5, [here]); + for (let i = 0; i < 4; i++) { pending.splice(0).forEach(p => (p.url === url ? p.rej(new Error('503')) : p.res())); await flush(); await flush(); } + assert.equal(layer.tiles.get(key).state, 'error'); + for (let k = 0; k < 40; k++) layer.update(0.5, [here, ...grid(-30 + k, 100, 0.05)]); // the view keeps the tile, the cache fills up + const e = layer.tiles.get(key); + assert.ok(layer.tiles.size <= layer.cacheSize, 'the cache did trim'); + assert.ok(e && e.fails >= 1, 'kept with its failure count (its backoff survives)'); +}); + +test('a new tile URL (regions rebuilt) drops every tile and reloads', async () => { + const { layer, pending } = setup(); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + await settle(pending); + assert.ok(layer.tiles.size > 0); + layer.setUrl('/tiles/v4-new/{layer}/{z}/{x}/{y}.jpg', '/tiles/v4-new/mesh/{z}/{x}/{y}.bin'); + assert.equal(layer.tiles.size, 0); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + assert.match(pending.at(-1).url, /^\/tiles\/v4-new\//); +}); + +test('the default back-off timer works where setTimeout must be called on the window (Firefox)', async () => { + const orig = globalThis.setTimeout, unhandled = []; + const onRej = e => unhandled.push(e); + globalThis.setTimeout = function (fn, ms) { // as Firefox: a method call on another object throws + if (this !== undefined && this !== globalThis) throw new TypeError("'setTimeout' called on an object that does not implement interface Window."); + return orig(fn, ms); + }; + process.on('unhandledRejection', onRej); + try { + const view = new GlobeView(fakeRenderer(), () => {}), pending = []; + const load = url => new Promise((res, rej) => pending.push({ url, rej })); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', load }); + layer.setLayers('relief', ''); + layer.update(1.0, [{ lat: 10, lon: 20 }]); + pending.splice(0).forEach(p => p.rej(new Error('404'))); // e.g. a tile of a region set the server just retired + await flush(); await flush(); await flush(); + assert.deepEqual(unhandled, []); + assert.ok([...layer.tiles.values()].some(e => e.state === 'error'), 'the tile backs off'); + } finally { + globalThis.setTimeout = orig; + process.off('unhandledRejection', onRej); + } +}); + +test('heights: water on draws the surface at 0 m, water off the floor', async () => { + const { MESH_SEGS } = await import('../js/tiles.js'); + const n = (MESH_SEGS + 1) ** 2, h = new Float32Array(n).fill(-3000), water = new Uint8Array(n).fill(1); + h[0] = 200; water[0] = 0; + const view = new GlobeView(fakeRenderer(), () => {}); + const tex = () => Promise.resolve(new THREE.Texture()); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: tex, + loadMesh: () => Promise.resolve({ h, water }) }); + layer.setLayers('relief', ''); + layer.setTerrain(1); + layer.update(0.05, [{ lat: 10, lon: 20 }]); + for (let i = 0; i < 20; i++) await flush(); + assert.equal(layer.heightAt(10, 20), 0, 'sea: the water surface'); + layer.setWater(false); + assert.ok(layer.heightAt(10, 20) < -2000, 'water off: the floor'); +}); + +test('3D patches colour near-vertical faces as rock, not the texture at their foot', async () => { + const { STEEP_GLSL } = await import('../js/tilelayer.js'); + assert.match(STEEP_GLSL, /dFdx\(rel\)/); // slope from patch-relative positions: world ones (|p| ≈ 1) are float32 noise at street zoom + assert.match(STEEP_GLSL, /smoothstep\(0\.42, 0\.71/); // cos 65° … cos 45° + const { MESH_SEGS } = await import('../js/tiles.js'); + const n = (MESH_SEGS + 1) ** 2; + const view = new GlobeView(fakeRenderer(), () => {}); + const layer = new TileLayer(view, { url: '/t/{layer}/{z}/{x}/{y}.jpg', meshUrl: '/t/mesh/{z}/{x}/{y}.bin', + load: () => Promise.resolve(new THREE.Texture()), + loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) }); + layer.setLayers('relief', ''); + layer.setTerrain(1); + layer.update(0.05, [{ lat: 10, lon: 20 }]); + for (let i = 0; i < 20; i++) await flush(); + const mesh = [...layer.tiles.values()].find(e => e.mesh)?.mesh; + assert.match(mesh.material.fragmentShader, /steepColor\(c, vPos, vRel, limb\)/); + assert.match(mesh.material.vertexShader, /vRel = mat3\(modelMatrix\) \* position;/); +}); + +test('3D patches: a constant depth offset per zoom, none scaled by slope (it pulled the edge-on skirts over the ground: a line along tile edges)', async () => { + const { MESH_SEGS } = await import('../js/tiles.js'); + const n = (MESH_SEGS + 1) ** 2; + const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', + meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()), + loadMesh: () => Promise.resolve({ h: new Float32Array(n), water: new Uint8Array(n) }) }); + layer.setLayers('relief', ''); + layer.setTerrain(1); + layer.update(0.05, [{ lat: 10, lon: 20 }]); + for (let i = 0; i < 20; i++) await flush(); + const meshes = [...layer.tiles.values()].filter(e => e.mesh?.geometry.attributes.skirt).map(e => e.mesh); + assert.ok(meshes.length > 0); + for (const m of meshes) { + assert.equal(m.material.polygonOffsetFactor, 0); + assert.ok(m.material.polygonOffsetUnits < 0, 'finer zooms still win over a standing-in ancestor'); + } +}); + +test('3D patches: a fine grain fades in where the map texture is magnified, with seamless coordinates, never on water', async () => { + const { MESH_SEGS, detailOrigin } = await import('../js/tiles.js'); + const n = (MESH_SEGS + 1) ** 2, water = new Uint8Array(n); + water[0] = 1; + const layer = new TileLayer(new GlobeView(fakeRenderer(), () => {}), { url: '/t/{layer}/{z}/{x}/{y}.jpg', + meshUrl: '/t/mesh/{z}/{x}/{y}.bin', load: () => Promise.resolve(new THREE.Texture()), + loadMesh: () => Promise.resolve({ h: new Float32Array(n), water }) }); + layer.setLayers('relief', ''); + layer.setTerrain(1); + layer.update(0.05, [{ lat: 10, lon: 20 }]); + for (let i = 0; i < 20; i++) await flush(); + const e = [...layer.tiles.values()].find(t => t.mesh); + const m = e.mesh; + assert.deepEqual([...m.material.uniforms.detailOrg.value.toArray()], detailOrigin(e.t)); + assert.equal(m.geometry.attributes.wet.array[0], 1); + assert.equal(m.geometry.attributes.wet.array[1], 0); + assert.equal(m.material.uniforms.waterOn.value, 1); + layer.setWater(false); + assert.equal(m.material.uniforms.waterOn.value, 0, 'water off: the sea floor gets its grain too'); + assert.match(m.material.fragmentShader, /fineGrain\(/); + assert.match(m.material.fragmentShader, /fwidth\(/, 'fades in by how magnified the texture is'); +}); |
