aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/tests/tilelayer.test.mjs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
committergodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
commit3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch)
tree4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/tests/tilelayer.test.mjs
downloadworldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz
worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/tests/tilelayer.test.mjs')
-rw-r--r--viewer/tests/tilelayer.test.mjs402
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');
+});