diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/tests | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/tests')
37 files changed, 2591 insertions, 0 deletions
diff --git a/viewer/tests/_planet.mjs b/viewer/tests/_planet.mjs new file mode 100644 index 0000000..a1f9822 --- /dev/null +++ b/viewer/tests/_planet.mjs @@ -0,0 +1,3 @@ +// _planet.mjs — the test world (import first): 12,742 km, 20° tilt, 216-day year, 31.149 h days of 46,656 moments +import { PLANET } from '../js/planet.js'; +Object.assign(PLANET, { radius_km: 12742, tilt_deg: 20, year_days: 216, day_hours: 31.149, moment_s: 2.403473 }); diff --git a/viewer/tests/atmosphere.test.mjs b/viewer/tests/atmosphere.test.mjs new file mode 100644 index 0000000..79f183a --- /dev/null +++ b/viewer/tests/atmosphere.test.mjs @@ -0,0 +1,24 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import './importmap.mjs'; + +const { atmosphere, SKY_FULL_KM, SKY_NONE_KM } = await import('../js/atmosphere.js'); +const lum = c => c[0] + c[1] + c[2]; + +test('full sky near the ground, none in space, a smooth fade between', () => { + assert.equal(atmosphere(5).amount, 1); + assert.equal(atmosphere(SKY_FULL_KM).amount, 1); + assert.equal(atmosphere(SKY_NONE_KM).amount, 0); + assert.equal(atmosphere(5000).amount, 0); + const m = atmosphere(60).amount; + assert.ok(m > 0.2 && m < 0.8); +}); + +test('day sky is blue, lighter at the horizon; night is dark; Sky off = day', () => { + const d = atmosphere(1, null); + assert.ok(d.zenith[2] > d.zenith[0] && lum(d.horizon) > lum(d.zenith)); + const n = atmosphere(1, -1); + assert.ok(lum(n.horizon) < 0.3 * lum(d.horizon)); + assert.deepEqual(atmosphere(1, 1), d); +}); diff --git a/viewer/tests/camera.test.mjs b/viewer/tests/camera.test.mjs new file mode 100644 index 0000000..f2ed115 --- /dev/null +++ b/viewer/tests/camera.test.mjs @@ -0,0 +1,139 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toVec, R_KM } from '../js/geo.js'; +import { MIN_ALT, MAX_ALT, CLEARANCE_M } from '../js/limits.js'; +import { R_M, TILT_FULL, TILT_NONE, frame, maxPitch, clampPose, eyeOf, eyeAltitude, rangeForAltitude, headingOf, + rotatePose, panPose, lift, kmPerPxAt } from '../js/camera.js'; + +const near = (a, b, eps, m = '') => assert.ok(Math.abs(a - b) <= eps, `${m} ${a} vs ${b}`); +const nearV = (a, b, eps) => a.forEach((v, i) => near(v, b[i], eps, `[${i}]`)); +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const sub = (a, b) => a.map((v, i) => v - b[i]); +const len = a => Math.hypot(...a); +const pose = o => clampPose({ lat: 40, lon: -10, range: 0.01, heading: 0, pitch: 0, ...o }); + +test('frame: east, north, up are orthonormal and right-handed (east × north = up)', () => { + for (const [la, lo] of [[0, 0], [40, -10], [-65, 170], [90, 30], [-90, -120]]) { + const { east: e, north: n, up: u } = frame(la, lo); + for (const v of [e, n, u]) near(len(v), 1, 1e-12); + near(dot(e, n), 0, 1e-12); near(dot(e, u), 0, 1e-12); near(dot(n, u), 0, 1e-12); + nearV([e[1] * n[2] - e[2] * n[1], e[2] * n[0] - e[0] * n[2], e[0] * n[1] - e[1] * n[0]], u, 1e-12); + } + nearV(frame(0, 0).north, [0, 1, 0], 1e-12); +}); + +test('straight down: the eye is above the target, screen-up is the heading', () => { + const p = pose({ heading: 0 }), { eye, up, target } = eyeOf(p); + nearV(target, toVec(40, -10), 1e-12); + nearV(eye, toVec(40, -10).map(v => v * 1.01), 1e-12); + nearV(up, frame(40, -10).north, 1e-12); + nearV(eyeOf(pose({ heading: 90 })).up, frame(40, -10).east, 1e-12); +}); + +test('tilted: range is the eye–target distance, pitch the angle from vertical, eye behind the heading', () => { + const p = pose({ heading: 30, pitch: 60 }), { eye, up, target } = eyeOf(p), back = sub(eye, target); + near(len(back), 0.01, 1e-12); + near(Math.acos(dot(back, target) / len(back)) * 180 / Math.PI, 60, 1e-9); + near(headingOf(40, -10, sub(target, eye)), 30, 1e-9); + near(dot(up, sub(target, eye)), 0, 1e-12); + near(len(up), 1, 1e-12); +}); + +test('clampPose: range limits, tilt only when low (ramp 1,000 → 2,000 km), heading wraps, junk → defaults', () => { + assert.equal(clampPose({ range: 99 }).range, MAX_ALT); + assert.equal(clampPose({ range: 0 }).range, MIN_ALT); + near(TILT_FULL * R_KM, 1000, 1e-9); near(TILT_NONE * R_KM, 2000, 1e-9); + assert.equal(pose({ range: 500 / R_KM, pitch: 70 }).pitch, 70); + assert.equal(pose({ range: 3000 / R_KM, pitch: 70 }).pitch, 0); + near(maxPitch(1500 / R_KM), 40, 1e-9); + assert.equal(pose({ range: 500 / R_KM, pitch: 95 }).pitch, 80); + assert.equal(pose({ heading: -30 }).heading, 330); + assert.equal(pose({ heading: 720 }).heading, 0); + const j = clampPose({ lat: NaN, lon: 'x', range: undefined, heading: null, pitch: Infinity }); + assert.deepEqual([j.lat, j.lon, j.range, j.heading], [0, 0, 2, 0]); + assert.equal(pose({ lon: 190 }).lon, -170); +}); + +test('eye altitude ↔ range round trip', () => { + for (const pt of [0, 30, 80]) for (const a of [1e-6, 3e-4, 0.05]) near(eyeAltitude(rangeForAltitude(a, pt), pt), a, 1e-15); + near(eyeAltitude(0.01, 0), 0.01, 1e-15); +}); + +test('rotatePose moves the target exactly; a meridian move keeps the heading; poles and the antimeridian stay finite', () => { + const p = pose({ heading: 45, pitch: 30 }), q = rotatePose(p, toVec(40, -10), toVec(41, -10)); + near(q.lat, 41, 1e-9); near(q.lon, -10, 1e-9); near(q.heading, 45, 1e-9); + assert.equal(q.range, p.range); assert.equal(q.pitch, p.pitch); + const r = rotatePose(pose({ lon: 179.5 }), toVec(40, 179.5), toVec(40, -179.5)); + near(r.lon, -179.5, 1e-9); + const s = rotatePose(pose({ lat: 89.9, heading: 10 }), toVec(89.9, -10), toVec(90, 0)); + assert.ok([s.lat, s.lon, s.heading].every(Number.isFinite)); + assert.deepEqual(rotatePose(p, toVec(1, 2), toVec(1, 2)), p); +}); + +test('rotatePose is a rigid turn: the eye keeps its distance to the moved point (the grab invariant)', () => { + const p = pose({ heading: 200, pitch: 50 }), a = toVec(40.2, -9.9), b = toVec(40.05, -10.3); + const e1 = eyeOf(p).eye, e2 = eyeOf(rotatePose(p, a, b)).eye; + near(len(sub(e2, b)), len(sub(e1, a)), 1e-12); +}); + +test('panPose: screen-up moves forward along the heading, right moves right, by a fraction of the range', () => { + const up = panPose(pose({ heading: 0 }), 0, -0.2); + near(up.lat - 40, (0.2 * 0.01 * 180) / Math.PI, 1e-9); near(up.lon, -10, 1e-9); + const right = panPose(pose({ lat: 0, lon: 0, heading: 0 }), 0.2, 0); + near(right.lat, 0, 1e-9); near(right.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9); + const east = panPose(pose({ lat: 0, lon: 0, heading: 90 }), 0, -0.2); + near(east.lon, (0.2 * 0.01 * 180) / Math.PI, 1e-9); + assert.ok(panPose(pose({ range: 5 }), 0, -1).lat - 40 <= 0.5 * 180 / Math.PI + 1e-9, 'big steps are capped'); +}); + +test('lift: never below the ground + 20 m (sea level when the ground is lower or unknown)', () => { + const low = pose({ range: 1e-9, pitch: 0 }), g = 1500; + near(eyeAltitude(lift(low, g).range, 0) * R_M, g + CLEARANCE_M, 1e-6); + near(eyeAltitude(lift(low, -300).range, 0) * R_M, CLEARANCE_M, 1e-6); + const tilted = pose({ range: 2e-6, pitch: 80 }); + near(eyeAltitude(lift(tilted, 0).range, 80) * R_M, CLEARANCE_M, 1e-6); + const high = pose({ range: 0.01 }); + assert.equal(lift(high, 1500), high); +}); + +test('kmPerPxAt: distance × the pixel angle', () => { + const { eye } = eyeOf(pose()), fov = 35, h = 800; + near(kmPerPxAt(eye, { lat: 40, lon: -10 }, fov, h), (0.01 * R_KM * 2 * Math.tan((fov * Math.PI) / 360)) / h, 1e-9); +}); + +test('panning keeps the compass heading (north stays where it was)', () => { + const p = pose({ lat: 20, lon: 0, range: 2, heading: 0 }); + assert.equal(panPose(p, 0.2, 0).heading, 0); + assert.equal(panPose(pose({ heading: 30, pitch: 40 }), 0, -0.2).heading, 30); +}); + +test('the target sits on the ground: range, altitude and the floor measure from it', () => { + const g = 3000, p = pose({ range: 20 / R_M, ground: g }); + const { eye, target } = eyeOf(p); + near(len(target), 1 + g / R_M, 1e-15); + near(len(sub(eye, target)), 20 / R_M, 1e-15); + near(eyeAltitude(p.range, 0, g) * R_M, g + 20, 1e-6); + for (const pt of [0, 45, 80]) near(eyeAltitude(rangeForAltitude(0.001, pt, g), pt, g), 0.001, 1e-15); + near(eyeAltitude(lift(pose({ range: 1e-9, ground: g }), g).range, 0, g) * R_M, g + CLEARANCE_M, 1e-6); + assert.equal(clampPose({ ground: -5 }).ground, 0); + assert.equal(clampPose({}).ground, 0); +}); + +test('holding the forward key across a pole keeps going (no bouncing back and forth)', async () => { + const { angleRad } = await import('../js/geo.js'); + let p = pose({ lat: 89.5, lon: 0, range: 0.01, heading: 0 }), last = 0; + const start = { lat: 89.5, lon: 0 }; + for (let i = 0; i < 12; i++) { + p = panPose(p, 0, -0.2); + const d = angleRad(start, p); + assert.ok(d > last, `press ${i}: ${d} after ${last} (lat ${p.lat}, lon ${p.lon})`); + last = d; + } +}); + +test('lift: a floor below sea level only when asked', () => { + const p = { lat: 0, lon: 0, range: MIN_ALT, heading: 0, pitch: 0, ground: -3000 }; + near(eyeAltitude(lift(p, -3000).range, 0, -3000) * R_M, CLEARANCE_M, 1, 'default: 20 m above sea level'); + near(eyeAltitude(lift(p, -3000, true).range, 0, -3000) * R_M, -3000 + CLEARANCE_M, 1, 'below: 20 m above the floor'); +}); diff --git a/viewer/tests/exports.test.mjs b/viewer/tests/exports.test.mjs new file mode 100644 index 0000000..c3735fb --- /dev/null +++ b/viewer/tests/exports.test.mjs @@ -0,0 +1,46 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { samplesFor, squareOutline, exportSizeMB, exportStatusText } from '../js/exports.js'; + +test('samples: the smallest 2^n + 1 covering the size (as the server)', () => { + assert.equal(samplesFor(40, 10), 4097); + assert.equal(samplesFor(100, 2000), 65); + assert.equal(samplesFor(5, 20), 257); +}); + +test('the square outline: corners size/√2 from the centre, closed', () => { + const R = 12742, pts = squareOutline(0, 0, 100); + assert.deepEqual(pts[0], pts[pts.length - 1]); + const d = p => R * Math.acos(Math.cos(p.lat * Math.PI / 180) * Math.cos(p.lon * Math.PI / 180)); + assert.ok(Math.abs(d(pts[0]) - 50 * Math.SQRT2) < 0.05); + const north = pts.reduce((a, b) => (b.lat > a.lat ? b : a)); + assert.ok(Math.abs(north.lat * Math.PI / 180 * R - 50) < 0.1, 'the north edge 50 km up'); +}); + +test('size estimate and status', () => { + assert.ok(exportSizeMB(4097) > 100 && exportSizeMB(4097) < 200, "the island export was 128 MB"); + assert.equal(exportStatusText({ state: 'running', name: 'x', fraction: 0.5 }), 'Exporting x… 50%'); + assert.equal(exportStatusText({ state: 'failed', name: 'x', error: 'boom' }), 'Export x failed: boom'); + assert.equal(exportStatusText({ state: 'idle' }), ''); +}); + +test('polling while an export runs keeps what is being typed', async () => { + const { installFakeDom, FakeEl } = await import('./fakedom.mjs'); + installFakeDom(); + let calls = 0; + globalThis.fetch = async () => ({ ok: true, status: 200, headers: { get: () => 'application/json' }, + json: async () => ({ exports: [], status: { state: 'running', name: 'x', fraction: 0.1 * ++calls } }) }); + const { ExportPanel } = await import('../js/exportpanel.js'); + const box = new FakeEl('section'); + const p = new ExportPanel(box, { getCenter: () => ({ lat: 1, lon: 2 }), onSquare: () => {} }); + p.visible = true; + await p.load(); + const input = box.find(e => e.tag === 'input'); + input.value = 'my-area'; + await p.load(); // a poll + clearTimeout(p.timer); + assert.equal(box.find(e => e.tag === 'input'), input, 'the same input, not rebuilt'); + assert.equal(input.value, 'my-area'); + assert.match(box.textContent, /Exporting x… 20%/); +}); diff --git a/viewer/tests/fakedom.mjs b/viewer/tests/fakedom.mjs new file mode 100644 index 0000000..5b2fadd --- /dev/null +++ b/viewer/tests/fakedom.mjs @@ -0,0 +1,29 @@ +// Minimal DOM stand-in for node tests of the panels: just what ui.el() and the panels touch. +export class FakeEl { + constructor(tag) { Object.assign(this, { tag, kids: [], attrs: {}, on: {}, style: {}, value: '', hidden: false }); } + setAttribute(k, v) { this.attrs[k] = v; } + get className() { return this.attrs.class ?? ''; } + addEventListener(t, f) { (this.on[t] ??= []).push(f); } + fire(t, e = {}) { for (const f of this.on[t] ?? []) f({ preventDefault() {}, stopPropagation() {}, target: this, ...e }); } + append(...k) { this.kids.push(...k); } + replaceChildren(...k) { this.kids = k; } + focus() {} + blur() {} + getBoundingClientRect() { return { left: 0, top: 0, width: 320, height: 130 }; } + get textContent() { return this.kids.map(k => (typeof k === 'string' ? k : k.textContent)).join(''); } + set textContent(v) { this.kids = [String(v)]; } + find(pred) { + for (const k of this.kids) { + if (typeof k === 'string') continue; + if (pred(k)) return k; + const r = k.find(pred); + if (r) return r; + } + return null; + } +} + +export function installFakeDom() { + globalThis.document = { createElement: t => new FakeEl(t), createElementNS: (_, t) => new FakeEl(t), + querySelector: () => null, querySelectorAll: () => [] }; +} diff --git a/viewer/tests/fixtures/projections.json b/viewer/tests/fixtures/projections.json new file mode 100644 index 0000000..cb29f74 --- /dev/null +++ b/viewer/tests/fixtures/projections.json @@ -0,0 +1,42 @@ +{ + "ee_xmax": 2.7066299836960743, + "ee_ymax": 1.317362759157413, + "equal_earth": [ + { + "lat": 0.0, + "lon": 0.0, + "x": 0.0, + "y": 0.0 + }, + { + "lat": 30.0, + "lon": 120.0, + "x": 1.6878011172181764, + "y": 0.592935119848017 + }, + { + "lat": -60.0, + "lon": -170.0, + "x": -1.9257789715953615, + "y": -1.0883008355053196 + }, + { + "lat": 89.0, + "lon": 45.0, + "x": 0.40107231234847257, + "y": 1.3170644437079506 + }, + { + "lat": 10.0, + "lon": -179.0, + "x": -2.672037641978515, + "y": 0.20204243960029805 + }, + { + "lat": -45.5, + "lon": 33.3, + "x": 0.42759882515649705, + "y": -0.8685664740830532 + } + ] +}
\ No newline at end of file diff --git a/viewer/tests/flatmap.test.mjs b/viewer/tests/flatmap.test.mjs new file mode 100644 index 0000000..2cafb36 --- /dev/null +++ b/viewer/tests/flatmap.test.mjs @@ -0,0 +1,83 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const { FlatView, MAX_ZOOM } = await import('../js/flatmap.js'); +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); +const ev = (type, props) => Object.assign(new Event(type, { cancelable: true }), { button: 0, pointerId: 1, deltaMode: 0, ...props }); + +function flat(kind, st = { lat: 20, lon: 30, zoom: 3 }) { + const r = fakeRenderer(); + const v = new FlatView(r, kind, () => {}); + v.resize(800, 400); + v.setState(st); + v.activate(); + return { v, canvas: r.domElement }; +} + +for (const kind of ['equal_earth', 'plate_carree']) { + test(`${kind}: state round trip, zoom clamped`, () => { + const s = flat(kind).v.getState(); + near(s.lat, 20, 1e-9); near(s.lon, 30, 1e-9); + assert.equal(s.zoom, 3); + assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 1e9 }).v.getState().zoom, MAX_ZOOM); + assert.equal(MAX_ZOOM, 20000); + assert.equal(flat(kind, { lat: 0, lon: 0, zoom: 0 }).v.getState().zoom, 1); + }); + + test(`${kind}: pick and toScreen agree, incl. near the seam and poles`, () => { + const { v } = flat(kind, { lat: 0, lon: 0, zoom: 1 }); + for (const [lat, lon] of [[0, 0], [45, 179.9], [-89, -120], [60, -179.9]]) { + const q = v.toScreen(lat, lon), p = v.pick(q.x, q.y); + near(p.lat, lat, 1e-6); near(p.lon, lon, 1e-6); + } + }); + + test(`${kind}: wheel zoom keeps the point under the cursor`, () => { + const { v, canvas } = flat(kind, { lat: 10, lon: 10, zoom: 2 }); + const before = v.pick(600, 120); + canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 600, clientY: 120 })); + assert.ok(v.getState().zoom > 2); + const after = v.pick(600, 120); + near(after.lat, before.lat, 1e-6); near(after.lon, before.lon, 1e-6); + }); + + test(`${kind}: drag pans with the cursor; an inactive view ignores input`, () => { + const { v, canvas } = flat(kind, { lat: 0, lon: 0, zoom: 4 }); + const p = v.pick(400, 200); + canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 })); + canvas.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 200 })); + canvas.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 200 })); + const q = v.pick(300, 200); + near(q.lat, p.lat, 1e-6); near(q.lon, p.lon, 1e-6); + v.deactivate(); + const s = v.getState(); + canvas.dispatchEvent(ev('wheel', { deltaY: -300, clientX: 10, clientY: 10 })); + assert.deepEqual(v.getState(), s); + }); +} + +test('equal earth: outside the outline picks null', () => { + assert.equal(flat('equal_earth', { lat: 0, lon: 0, zoom: 1 }).v.pick(2, 2), null); +}); + +test('long lines are drawn along the great circle (bowing poleward), not straight in the projection', () => { + const { v } = flat('plate_carree', { lat: 0, lon: 0, zoom: 1 }); + v.setLines('m', [[{ lat: 40, lon: -60 }, { lat: 40, lon: 60 }]], 0xffffff); + const pos = v.lines.objects.get('m').geometry.attributes.position.array; + assert.ok(pos.length / 6 > 10, `${pos.length / 6} segments`); + const maxLat = Math.max(...pos.filter((_, i) => i % 3 === 1)) * 180 / Math.PI; + assert.ok(maxLat > 50, `peak latitude ${maxLat}`); +}); + +test('equal earth: the centre cannot leave the outline, so the saved state reproduces the view', () => { + const { v, canvas } = flat('equal_earth', { lat: 60, lon: 170, zoom: 8 }); + canvas.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 200 })); + canvas.dispatchEvent(ev('pointermove', { clientX: 100, clientY: 200 })); // pan east past lon 180 + canvas.dispatchEvent(ev('pointerup', { clientX: 100, clientY: 200 })); + const s = v.getState(), before = v.toWorld(400, 200); + v.setState(s); + const after = v.toWorld(400, 200); + near(after.X, before.X, 1e-9); near(after.Y, before.Y, 1e-9); +}); diff --git a/viewer/tests/geo.test.mjs b/viewer/tests/geo.test.mjs new file mode 100644 index 0000000..4ebb2ea --- /dev/null +++ b/viewer/tests/geo.test.mjs @@ -0,0 +1,77 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as G from '../js/geo.js'; + +const near = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('lat/lon <-> vector round trip incl. poles and seam', () => { + for (const [lat, lon] of [[0, 0], [45, 120], [-60, -170], [89.999, 10], [-89.999, -100], [10, 179.999], [10, -179.999]]) { + const p = G.toLatLon(G.toVec(lat, lon)); + near(p.lat, lat, 1e-9); + near(p.lon, lon, 1e-7); + } + const pole = G.toLatLon(G.toVec(90, 0)); + near(pole.lat, 90, 1e-9); + assert.ok(Number.isFinite(pole.lon)); +}); + +test('distances on the 12,742 km sphere', () => { + near(G.distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }), (Math.PI / 2) * 12742, 1e-6); + near(G.distanceKm({ lat: 90, lon: 0 }, { lat: -90, lon: 0 }), Math.PI * 12742, 1e-6); + near(G.distanceKm({ lat: 10, lon: 179 }, { lat: 10, lon: -179 }), G.distanceKm({ lat: 10, lon: 0 }, { lat: 10, lon: 2 }), 1e-6); +}); + +test('interpolate follows the great circle', () => { + const pts = G.interpolate({ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, 10); + assert.equal(pts.length, 10); + near(pts[5].lon, 50, 1e-9); + near(pts[5].lat, 0, 1e-9); +}); + +test('spherical polygon area', () => { + near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 90 }, { lat: 90, lon: 0 }]), (4 * Math.PI * 12742 ** 2) / 8, 1); + const cell = ((12742 * Math.PI) / 180) ** 2; + near(G.polygonAreaKm2([{ lat: 0, lon: 0 }, { lat: 0, lon: 1 }, { lat: 1, lon: 1 }, { lat: 1, lon: 0 }]), cell, cell * 0.01); +}); + +test('equal earth matches mapgen/projections.py', () => { + const fx = JSON.parse(readFileSync(new URL('./fixtures/projections.json', import.meta.url))); + near(G.EE_XMAX, fx.ee_xmax); + near(G.EE_YMAX, fx.ee_ymax); + for (const p of fx.equal_earth) { + const q = G.project('equal_earth', p.lat, p.lon); + near(q.x, p.x); + near(q.y, p.y); + const r = G.unproject('equal_earth', p.x, p.y); + near(r.lat, p.lat, 1e-6); + near(r.lon, p.lon, 1e-6); + } + assert.equal(G.unproject('equal_earth', G.EE_XMAX, G.EE_YMAX), null); + near(G.unproject('equal_earth', 0, G.EE_YMAX).lat, 90, 1e-6); // pole survives rounding +}); + +test('plate carree projection', () => { + const q = G.project('plate_carree', 45, -90); + near(q.x, -Math.PI / 2); + near(q.y, Math.PI / 4); + assert.equal(G.unproject('plate_carree', 4, 0), null); +}); + +test('sphere mesh: uv follows lat/lon and faces point outward', () => { + const m = G.sphereMesh(8, 16); + for (let i = 0; i < m.uvs.length / 2; i++) { + const ll = G.toLatLon([m.positions[3 * i], m.positions[3 * i + 1], m.positions[3 * i + 2]]); + const u = m.uvs[2 * i], v = m.uvs[2 * i + 1]; + near((ll.lat + 90) / 180, v, 1e-6); + if (Math.abs(ll.lat) < 89.9 && u > 1e-6 && u < 1 - 1e-6) near((ll.lon + 180) / 360, u, 1e-6); + } + assert.equal(m.indices.length, 8 * 16 * 6); + const q = 3 * 16 * 6 + 5 * 6; // a quad just north of the equator + const P = k => [m.positions[3 * k], m.positions[3 * k + 1], m.positions[3 * k + 2]]; + const [a, b, c] = [P(m.indices[q]), P(m.indices[q + 1]), P(m.indices[q + 2])]; + const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], w = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + assert.ok(n[0] * a[0] + n[1] * a[1] + n[2] * a[2] > 0, 'triangles wind counter-clockwise seen from outside'); +}); diff --git a/viewer/tests/gestures.test.mjs b/viewer/tests/gestures.test.mjs new file mode 100644 index 0000000..3236f44 --- /dev/null +++ b/viewer/tests/gestures.test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { twoFinger, Touches } = await import('../js/gestures.js'); +const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} ≉ ${b}`); + +test('spreading the fingers to twice the distance is scale 2, no turn, no tilt', () => { + const g = twoFinger({ x: 90, y: 100 }, { x: 110, y: 100 }, { x: 80, y: 100 }, { x: 120, y: 100 }); + close(g.scale, 2); close(g.rot, 0); close(g.tilt, 0); + assert.deepEqual(g.mid, { x: 100, y: 100 }); assert.deepEqual(g.move, { x: 0, y: 0 }); +}); + +test('turning the pair a quarter clockwise on screen (y down) is +90°', () => { + const g = twoFinger({ x: -10, y: 0 }, { x: 10, y: 0 }, { x: 0, y: -10 }, { x: 0, y: 10 }); + close(g.rot, 90); close(g.scale, 1); +}); + +test('turns take the short way across ±180°', () => { + const g = twoFinger({ x: 0, y: 0 }, { x: -10, y: 1 }, { x: 0, y: 0 }, { x: -10, y: -1 }); + assert.ok(Math.abs(g.rot) < 12, String(g.rot)); +}); + +test('both fingers moving up together tilt by the smaller move; a vertical pinch does not', () => { + close(twoFinger({ x: 0, y: 100 }, { x: 50, y: 100 }, { x: 0, y: 80 }, { x: 50, y: 70 }).tilt, -20); + close(twoFinger({ x: 0, y: 100 }, { x: 0, y: 150 }, { x: 0, y: 90 }, { x: 0, y: 160 }).tilt, 0); +}); + +test('Touches reports a gesture only while two fingers are down', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 0 }); + assert.equal(t.move(1, { x: 5, y: 0 }), null); + t.down(2, { x: 20, y: 0 }); + const g = t.move(2, { x: 35, y: 0 }); // 15 → 30 apart + close(g.scale, 2); + assert.equal(t.move(9, { x: 0, y: 0 }), null, 'unknown pointer'); + t.up(1); + assert.equal(t.size, 1); + assert.deepEqual(t.other(1), { id: 2, at: { x: 35, y: 0 } }); +}); + +test('fingers moving up one event at a time still tilt (browsers send one move per finger)', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 100 }); t.down(2, { x: 50, y: 100 }); + let tilt = 0; + for (let s = 1; s <= 5; s++) { + tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt; + tilt += t.move(2, { x: 50, y: 100 - 10 * s }).tilt; + } + close(tilt, -50); +}); + +test('a vertical pinch, one finger at a time, does not tilt', () => { + const t = new Touches(); + t.down(1, { x: 0, y: 100 }); t.down(2, { x: 0, y: 200 }); + let tilt = 0; + for (let s = 1; s <= 5; s++) { + tilt += t.move(1, { x: 0, y: 100 - 10 * s }).tilt; + tilt += t.move(2, { x: 0, y: 200 + 10 * s }).tilt; + } + close(tilt, 0); +}); diff --git a/viewer/tests/globe.test.mjs b/viewer/tests/globe.test.mjs new file mode 100644 index 0000000..147a16a --- /dev/null +++ b/viewer/tests/globe.test.mjs @@ -0,0 +1,377 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const { GlobeView, MIN_ALT, MAX_ALT } = await import('../js/globe.js'); +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +function globe(st = { lat: 40, lon: -10, alt: 0.5 }) { + const g = new GlobeView(fakeRenderer(), () => {}); + g.resize(800, 400); + g.setState(st); + return g; +} + +test('state round trip; altitude clamped', () => { + const s = globe().getState(); + near(s.lat, 40, 1e-6); near(s.lon, -10, 1e-6); near(s.alt, 0.5, 1e-9); + assert.equal(globe({ lat: 0, lon: 0, alt: 99 }).getState().alt.toFixed(6), MAX_ALT.toFixed(6)); + assert.equal(globe({ lat: 0, lon: 0, alt: 0 }).getState().alt.toFixed(6), MIN_ALT.toFixed(6)); +}); + +test('screen centre picks the sub-camera point; off-globe picks null', () => { + const p = globe().pick(400, 200); + near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6); + assert.equal(globe({ lat: 0, lon: 0, alt: 4 }).pick(2, 2), null); +}); + +test('toScreen inverts pick; far side hidden', () => { + const g = globe({ lat: 10, lon: 179, alt: 0.3 }); // straddles the antimeridian + for (const [x, y] of [[100, 50], [400, 200], [700, 350], [620, 90]]) { + const p = g.pick(x, y); + assert.ok(p && Math.abs(p.lon) <= 180, 'finite pick'); + const q = g.toScreen(p.lat, p.lon); + near(q.x, x, 1e-6); near(q.y, y, 1e-6); + } + assert.equal(g.toScreen(-10, 1), null); +}); + +test('poles stay finite', () => { + for (const lat of [90, -90]) { + const s = globe({ lat, lon: 0, alt: 1 }).getState(); + assert.ok(Number.isFinite(s.lat) && Number.isFinite(s.lon) && Math.abs(Math.abs(s.lat) - 90) < 1e-3, JSON.stringify(s)); + } +}); + +test('overlay lines are clipped to the visible cap (far-side lines never show through when drawn on top)', async () => { + const THREE = await import('three'); + const { toVec } = await import('../js/geo.js'); + const g = globe({ lat: 10, lon: 20, alt: 0.01 }); + const plane = g.lines.clip?.[0]; + assert.ok(plane, 'lines carry a clipping plane'); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 20))) >= 0); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-10, -160))) < 0); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(10, 60))) < 0, '40° away is beyond the horizon at 64 km up'); + g.setState({ lat: -30, lon: 100, alt: 2 }); + assert.ok(plane.distanceToPoint(new THREE.Vector3(...toVec(-30, 100))) >= 0); +}); + +const ev = (type, o = {}) => Object.assign(new Event(type, { cancelable: true }), + { clientX: 0, clientY: 0, button: 0, buttons: 1, shiftKey: false, pointerId: 1, deltaY: 0, deltaMode: 0, ...o }); + +test('tilted state round trip; the pitch levels out when high', async () => { + const { R_KM } = await import('../js/geo.js'); + const s = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }).getState(); + near(s.lat, 40, 1e-9); near(s.lon, -10, 1e-9); near(s.alt, 0.01, 1e-12); near(s.hd, 30, 1e-9); near(s.pt, 60, 1e-9); + assert.equal(globe({ lat: 0, lon: 0, alt: 0.5, hd: 30, pt: 60 }).getState().pt, 0); + assert.equal(globe({ lat: 0, lon: 0, alt: 3000 / R_KM, pt: 60 }).getState().pt, 0); + const h = globe({ lat: 40, lon: -10, alt: 0.01, hd: 30, pt: 60 }); + h.setState({ lat: 1, lon: 2, alt: 2 }); + assert.deepEqual([h.getState().hd, h.getState().pt], [0, 0], 'no hd/pt = north up, straight down'); +}); + +test('tilted: the centre still picks the target, the top of the screen is sky', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, hd: 0, pt: 80 }); + const p = g.pick(400, 200); + near(p.lat, 40, 1e-6); near(p.lon, -10, 1e-6); + assert.equal(g.pick(400, 3), null); + assert.ok(g.pick(400, 390).lat < 40, 'the bottom of the screen is nearer ground, behind the target (south)'); +}); + +test('the eye never goes below 20 m; with 3D terrain, 20 m above the loaded ground × exaggeration', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = globe({ lat: 40, lon: -10, alt: 1e-9, pt: 0 }); + near(eyeAltitude(g.pose.range, g.pose.pitch) * R_M, 20, 1e-6); + g.terrain = 2; + g.tiles = { heightAt: () => 1000 }; + g.setState({ lat: 40, lon: -10, alt: 1e-9 }); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 2020, 1e-6); + g.tiles = { heightAt: () => 3000 }; // finer ground arrived under the camera + g.keepAboveGround(); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6); + g.tiles = { heightAt: () => null }; + g.keepAboveGround(); + near(eyeAltitude(g.pose.range, 0, g.pose.ground) * R_M, 6020, 1e-6, 'unknown ground never pulls the camera down'); +}); + +test('a restored view keeps its range from the ground that loads after it (a reload lands where it was)', async () => { + const { R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.resize(800, 400); + g.terrain = 1; + let h = null; + g.tiles = { heightAt: () => h }; + g.setState({ lat: -53, lon: -44, alt: 120 / R_M, pt: 50 }); // the URL's view, heights not loaded yet + g.keepAboveGround(); + h = 560; // coarse heights arrive + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + h = null; // tiles reloading: nothing moves + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + h = 575; // finer heights + g.keepAboveGround(); + near(g.pose.range * R_M, 120, 1e-6); + assert.equal(g.getState().alt, 120 / R_M, 'the URL round-trips'); + g.setPose({ ...g.pose, heading: 10 }); // the user moves: the eye stays put from here on + h = 700; + g.keepAboveGround(); + assert.ok(g.pose.range * R_M < 120 - 50, 'finer ground after a gesture keeps the eye, not the range'); +}); + +test('drag pans: the grabbed ground stays under the pointer', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(300, 150); + c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150 })); + c.dispatchEvent(ev('pointermove', { clientX: 350, clientY: 230 })); + c.dispatchEvent(ev('pointermove', { clientX: 420, clientY: 260 })); + c.dispatchEvent(ev('pointerup', { clientX: 420, clientY: 260 })); + const now = g.pick(420, 260); + near(now.lat, grabbed.lat, 1e-6); near(now.lon, grabbed.lon, 1e-6); +}); + +test('Shift+drag or right-drag: up tilts toward the horizon, sideways turns; the wheel zooms; off when inactive', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement; + c.dispatchEvent(ev('wheel', { deltaY: 100 })); + near(g.getState().alt, 0.01, 1e-15, 'inactive: ignored'); + g.activate(); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, shiftKey: true })); + c.dispatchEvent(ev('pointermove', { clientX: 400, clientY: 100, shiftKey: true })); + c.dispatchEvent(ev('pointerup', { clientX: 400, clientY: 100 })); + assert.ok(g.getState().pt > 30, `pitch ${g.getState().pt}`); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 300, button: 2, buttons: 2 })); + c.dispatchEvent(ev('pointermove', { clientX: 300, clientY: 300, buttons: 2 })); + c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 300, button: 2 })); + assert.ok(Math.abs(g.getState().hd - 25) < 1e-9, `heading ${g.getState().hd}`); + const e = ev('wheel', { deltaY: 100 }); + c.dispatchEvent(e); + assert.ok(g.getState().alt > 0.0105 && e.defaultPrevented); + const ctx = ev('contextmenu'); + c.dispatchEvent(ctx); + assert.ok(ctx.defaultPrevented, 'right button belongs to the map'); +}); + +test('pixel size grows with distance when tilted', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 75 }); + assert.ok(g.kmPerPxAt(g.pick(400, 60)) > 3 * g.kmPerPxAt(g.pick(400, 390))); +}); + +test('a long diagonal drag keeps north up and the grabbed ground under the pointer', () => { + const g = globe({ lat: 50, lon: 10, alt: 0.3 }), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(200, 100); + c.dispatchEvent(ev('pointerdown', { clientX: 200, clientY: 100 })); + for (let i = 1; i <= 10; i++) c.dispatchEvent(ev('pointermove', { clientX: 200 + 40 * i, clientY: 100 + 25 * i })); + c.dispatchEvent(ev('pointerup', { clientX: 600, clientY: 350 })); + assert.equal(g.getState().hd, 0); + const q = g.toScreen(grabbed.lat, grabbed.lon); + near(q.x, 600, 0.05); near(q.y, 350, 0.05); // within a twentieth of a pixel +}); + +test('tilted over 3D terrain, overlay lines are depth-tested (no rivers floating through mountains)', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.001 }); + assert.equal(g.lines.depthTest, false, 'straight down: lines drawn on top of the tiles'); + g.terrain = 1; + g.setState({ lat: 40, lon: -10, alt: 0.001, pt: 60 }); + assert.equal(g.lines.depthTest, true); +}); + +test('sky dome and haze near the ground, gone from orbit', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.0005, pt: 70 }); + assert.equal(g.dome.mesh.visible, true); + assert.ok(g.shade.hazeAmt.value > 0); + g.setState({ lat: 40, lon: -10, alt: 2 }); + assert.equal(g.dome.mesh.visible, false); + assert.equal(g.shade.hazeAmt.value, 0); +}); + +test('tilted over high 3D ground: tile samples hit the ground, nearer and finer than the sea-level pick', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + g.terrain = 1; + g.tiles = { heightAt: () => 3000 }; + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + const sea = g.pick(400, 200, false), s = g.groundSample(400, 200); + assert.ok(s.km < 0.95 * g.kmPerPxAt(sea), `${s.km} vs ${g.kmPerPxAt(sea)}`); + assert.ok(s.lat < sea.lat, 'the ground hit is nearer the camera (south of the sea-level point)'); + g.terrain = 0; + const f = g.groundSample(400, 200); + near(f.lat, sea.lat, 1e-9); near(f.km, g.kmPerPxAt(sea), 1e-9); + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 80 }); + assert.equal(g.pick(400, 3), null); + assert.equal(g.groundSample(400, 3), null, 'sky'); +}); + +test('ground sampling finds a slope rising above the eye (camera below the far ground level)', () => { + const g = globe({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 }); + g.terrain = 1; + g.tiles = { heightAt: lat => (lat > 40.02 ? 6000 : 500) }; // a wall of mountains just north of the target + g.setState({ lat: 40, lon: -10, alt: 0.0005, hd: 0, pt: 75 }); + const s = g.groundSample(400, 100); // this ray meets the wall face before the 500 m ground + assert.ok(s, 'hits the wall'); + assert.ok(s.lat > 40.02 && s.lat < 40.1, `wall face at ${s.lat}`); +}); + +function hillGlobe(h = 3000) { // tilted over flat 3D ground at h metres + const g = globe({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + g.terrain = 1; + g.tiles = { heightAt: () => h, heightGen: 1 }; + g.setState({ lat: 40, lon: -10, alt: 0.002, pt: 70 }); + return g; +} + +test('with 3D terrain a click picks the ground it sees; markers sit on that ground (toScreen inverts pick)', () => { + const g = hillGlobe(), sea = g.pick(400, 200, false), p = g.pick(400, 200); + assert.ok(p.lat < sea.lat - 0.001, 'the ground hit is nearer than the sea-level point behind it'); + for (const [x, y] of [[400, 200], [120, 330], [700, 90]]) { + const q = g.pick(x, y), s = g.toScreen(q.lat, q.lon); + near(s.x, x, 0.5); near(s.y, y, 0.5); + } +}); + +test('grabbing a hill in 3D keeps that hill under the pointer', () => { + const g = hillGlobe(), c = g.renderer.domElement; + g.activate(); + const grabbed = g.pick(400, 250); + c.dispatchEvent(ev('pointerdown', { clientX: 400, clientY: 250 })); + c.dispatchEvent(ev('pointermove', { clientX: 450, clientY: 290 })); + c.dispatchEvent(ev('pointerup', { clientX: 450, clientY: 290 })); + const s = g.toScreen(grabbed.lat, grabbed.lon); + near(s.x, 450, 0.5); near(s.y, 290, 0.5); +}); + +test('with 3D terrain close up, lines lie on the drawn ground (depth-tested); flat again without it', async () => { + const { R_M } = await import('../js/camera.js'); + const g = hillGlobe(1000); + g.setLines('rivers', [[{ lat: 40.01, lon: -10 }, { lat: 40.05, lon: -10.02 }]], 0x3399ff); + g.updateDrape(0); + let o = g.lines.objects.get('rivers'); + const a = o.geometry.attributes.position.array, r = Math.hypot(a[0] + o.position.x, a[1] + o.position.y, a[2] + o.position.z); + assert.ok(r > 1 + 1000 / R_M && r < 1 + 1100 / R_M, `${(r - 1) * R_M} m`); + assert.equal(o.material.depthTest, true); + assert.equal(o.material.clippingPlanes?.length ?? 0, 0, 'no horizon clip: depth hides what is behind hills'); + g.tiles.heightGen = 2; + g.tiles.heightAt = () => 2000; + g.updateDrape(1000); // heights changed: re-laid + o = g.lines.objects.get('rivers'); + const b = o.geometry.attributes.position.array; + assert.ok(Math.hypot(b[0] + o.position.x, b[1] + o.position.y, b[2] + o.position.z) > 1 + 2000 / R_M); + g.terrain = 0; + g.updateDrape(2000); + o = g.lines.objects.get('rivers'); + const c = o.geometry.attributes.position.array; + assert.ok(Math.hypot(c[0], c[1], c[2]) * o.scale.x < 1 + 600 / R_M, 'back near sea level (lifted alt × 0.02, as before)'); + assert.equal(o.position.length(), 0); +}); + +test('20 m above high ground: a wheel notch and an arrow key move by the height above the ground, not sea level', async () => { + const { R_M, eyeAltitude } = await import('../js/camera.js'); + const { panPose } = await import('../js/camera.js'); + const g = globe({ lat: 40, lon: -10, alt: 0.01 }), c = g.renderer.domElement; + g.terrain = 1; + g.tiles = { heightAt: () => 3000, heightGen: 1 }; + g.setState({ lat: 40, lon: -10, alt: 1e-9 }); + const clear = () => eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M - 3000; + near(clear(), 20, 1e-6); + g.activate(); + c.dispatchEvent(ev('wheel', { deltaY: 100 })); + assert.ok(clear() > 21 && clear() < 25, `one notch: ${clear()} m above the ground`); + const before = g.pick(400, 200); + g.setPose(panPose(g.pose, 0.2, 0)); + const after = g.pick(400, 200); + const movedM = Math.hypot(after.lat - before.lat, (after.lon - before.lon) * Math.cos(40 * Math.PI / 180)) * Math.PI / 180 * R_M; + assert.ok(movedM < 20, `ArrowRight moved ${movedM} m with a ~40 m wide view`); +}); + +test('dragging over a pole keeps the grabbed ground under the pointer', () => { + const g = globe({ lat: 89.8, lon: 30, alt: 0.01 }), c = g.renderer.domElement; + g.activate(); + for (const x0 of [400, 250]) { + const grabbed = g.pick(x0, 20); + c.dispatchEvent(ev('pointerdown', { clientX: x0, clientY: 20 })); + for (let i = 1; i <= 37; i++) c.dispatchEvent(ev('pointermove', { clientX: x0, clientY: 20 + 10 * i })); + c.dispatchEvent(ev('pointerup', { clientX: x0, clientY: 390 })); + const s = g.toScreen(grabbed.lat, grabbed.lon); + near(s.x, x0, 1); near(s.y, 390, 1); + } +}); + +test('looking straight down there is no haze (data colours match their legend); tilting brings it in', () => { + const g = globe({ lat: 40, lon: -10, alt: 40 / 12742 }); + assert.equal(g.shade.hazeAmt.value, 0); + g.setState({ lat: 40, lon: -10, alt: 40 / 12742, pt: 60 }); + assert.ok(g.shade.hazeAmt.value > 0); +}); + +test('controls follow one pointer; the right-click menu is only taken while the globe is active; no touch scrolling', async () => { + const { readFileSync } = await import('node:fs'); + const g = globe({ lat: 40, lon: -10, alt: 0.05 }), c = g.renderer.domElement; + const menu = ev('contextmenu'); + c.dispatchEvent(menu); + assert.equal(menu.defaultPrevented, false, 'inactive: the browser keeps its menu'); + g.activate(); + const before = g.getState(); + c.dispatchEvent(ev('pointerdown', { clientX: 300, clientY: 150, pointerId: 1 })); + c.dispatchEvent(ev('pointermove', { clientX: 500, clientY: 300, pointerId: 2 })); // a second finger + near(g.getState().lat, before.lat, 1e-12); + c.dispatchEvent(ev('pointerup', { clientX: 300, clientY: 150, pointerId: 1 })); + assert.match(readFileSync(new URL('../style.css', import.meta.url), 'utf8'), /#map canvas\s*\{[^}]*touch-action:\s*none/); +}); + +test('dry land below sea level (water on): the eye comes down to 20 m above it, not above 0 m', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: () => -14 }; // a dry hollow: the tiles draw it at −14 m (sea cells they draw at 0 m) + g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 60 }); + near(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M, -14 + 20, 1e-6); + near(g.pose.ground, -14, 1e-9); +}); + +test('the eye clears the drawn ground too, not only finer heights loaded but hidden (a coarser stand-in drawn higher)', async () => { + const { eyeAltitude, R_M } = await import('../js/camera.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: (lat, lon, shown) => (shown ? 30 : 0) }; // drawn: a stand-in 30 m up; loaded finer: 0 m + g.setPose({ lat: 0, lon: 0, range: 1e-9, heading: 0, pitch: 0 }); + assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6, 'not inside the drawn ground'); + g.keepAboveGround(); + assert.ok(eyeAltitude(g.pose.range, g.pose.pitch, g.pose.ground) * R_M >= 30 + 20 - 1e-6); +}); + +test('camera floor follows the water mode', () => { + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.tiles = { heightAt: () => (g.seaFloor ? -4000 : 0) }; // what the tiles draw: the water surface, or the floor + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + const above = g.camera.position.length(); + assert.ok(above > 1, 'water on: above sea level'); + g.seaFloor = true; + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + assert.ok(g.camera.position.length() < 1, 'water off: may dive below 0 m'); +}); + +test('water off: tiles reloading (no heights yet) keep the camera where it was, not at sea level', () => { + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 1; + g.seaFloor = true; + let h = -4000; + g.tiles = { heightAt: () => h }; + g.setPose({ lat: 0, lon: 0, range: 1e-6, heading: 0, pitch: 0 }); + const below = g.camera.position.length(); + assert.ok(below < 1); + h = null; // a layer switch dropped every tile + g.keepAboveGround(); + assert.ok(Math.abs(g.camera.position.length() - below) < 1e-9, 'not lifted to sea level'); +}); + +test('water off: the coarse sphere sinks below the deepest trench at any exaggeration', async () => { + const { R_KM } = await import('../js/geo.js'); + const g = new GlobeView(fakeRenderer(), () => {}); + g.terrain = 4; + g.seaFloor = true; + g.tiles = { heightAt: () => -2000 }; + g.setPose({ lat: 0, lon: 0, range: 0.01, heading: 0, pitch: 0 }); + assert.ok(g.base.scale.x < 1 - (11 * 4) / R_KM, `sphere at ${g.base.scale.x}`); +}); diff --git a/viewer/tests/hover.test.mjs b/viewer/tests/hover.test.mjs new file mode 100644 index 0000000..7b26be5 --- /dev/null +++ b/viewer/tests/hover.test.mjs @@ -0,0 +1,30 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hoverText } from '../js/hover.js'; + +const cell = fields => ({ cell: 'x', lat: 0, lon: 0, fields }); + +test('land shows elevation and life zone', () => { + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 2344.4 }, { key: 'ocean', value: false, text: 'no' }, + { key: 'lake', value: false, text: 'no' }, { key: 'holdridge', value: 3, text: 'cool temperate desert scrub' }])), + '2,344 m · cool temperate desert scrub'); +}); + +test('sea shows depth; lakes are named; procedural detail only when tiles show', () => { + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: -3300 }, { key: 'ocean', value: true, text: 'yes' }])), 'sea, 3,300 m deep'); + assert.equal(hoverText(cell([{ key: 'z_surface_m', value: 120 }, { key: 'ocean', value: false }, { key: 'lake', value: true }, + { key: 'holdridge', value: 1, text: 'boreal moist forest' }])), '120 m · lake · boreal moist forest'); + const both = cell([{ key: 'z_surface_m', value: 120 }, { key: 'z_detail_m', value: 131.6 }, { key: 'ocean', value: false }]); + assert.equal(hoverText(both), '120 m', 'zoomed out: the build value'); + assert.equal(hoverText(both, true), '132 m', 'procedural tiles showing: the detail value'); +}); + +test('a cell answer is reused near its centre, never under procedural detail', async () => { + const { canReuse } = await import('../js/hover.js'); + const c = { center: [10, 20], inner_km: 50 }; + assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, false), true); + assert.equal(canReuse(c, { lat: 11, lon: 20 }, false), false); + assert.equal(canReuse(c, { lat: 10.1, lon: 20 }, true), false); + assert.equal(canReuse(null, { lat: 10, lon: 20 }, false), false); +}); diff --git a/viewer/tests/importmap-hooks.mjs b/viewer/tests/importmap-hooks.mjs new file mode 100644 index 0000000..3769c21 --- /dev/null +++ b/viewer/tests/importmap-hooks.mjs @@ -0,0 +1,11 @@ +// Node module-resolution hook: resolve bare specifiers with the page's import map. +let map; +export function initialize(data) { map = data; } +export async function resolve(spec, ctx, next) { + for (const [k, v] of Object.entries(map.imports)) { + if (spec === k || (k.endsWith('/') && spec.startsWith(k))) { + return { url: new URL(v + spec.slice(k.length), map.base).href, shortCircuit: true }; + } + } + return next(spec, ctx); +} diff --git a/viewer/tests/importmap.mjs b/viewer/tests/importmap.mjs new file mode 100644 index 0000000..301d2bc --- /dev/null +++ b/viewer/tests/importmap.mjs @@ -0,0 +1,15 @@ +// Import first, then load viewer modules with dynamic import(): bare 'three' resolves as in index.html. +import { register } from 'node:module'; +import { readFileSync } from 'node:fs'; + +export const HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +export const IMPORTS = JSON.parse(HTML.match(/<script type="importmap">([\s\S]*?)<\/script>/)[1]).imports; +register('./importmap-hooks.mjs', import.meta.url, { data: { imports: IMPORTS, base: new URL('../', import.meta.url).href } }); + +export function fakeCanvas(width = 800, height = 400) { + const c = new EventTarget(); + Object.assign(c, { style: {}, ownerDocument: new EventTarget(), setPointerCapture() {}, releasePointerCapture() {}, + getRootNode: () => new EventTarget(), getBoundingClientRect: () => ({ left: 0, top: 0, width, height }) }); + return c; +} +export const fakeRenderer = () => ({ domElement: fakeCanvas(), render() {} }); diff --git a/viewer/tests/inspector.test.mjs b/viewer/tests/inspector.test.mjs new file mode 100644 index 0000000..ea9c456 --- /dev/null +++ b/viewer/tests/inspector.test.mjs @@ -0,0 +1,30 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { groupFields, fmtValue } from '../js/inspector.js'; + +test('fields grouped in order of first appearance', () => { + const g = groupFields([{ key: 'a', group: 'Terrain' }, { key: 'b', group: 'Climate' }, { key: 'c', group: 'Terrain' }]); + assert.deepEqual([...g.keys()], ['Terrain', 'Climate']); + assert.deepEqual(g.get('Terrain').map(f => f.key), ['a', 'c']); +}); + +test('values: text wins; large numbers rounded with separators; small ones to 2 decimals', () => { + assert.equal(fmtValue({ value: 3, text: 'Boreal moist forest', unit: '' }), 'Boreal moist forest'); + assert.equal(fmtValue({ value: 1234.5, unit: 'm' }), '1,235 m'); + assert.equal(fmtValue({ value: -3.456, unit: '°C' }), '-3.46 °C'); + assert.equal(fmtValue({ value: 0.2, unit: '' }), '0.2'); +}); + +test('inspect asks for the era and shows its label', async () => { + const { installFakeDom, FakeEl } = await import('./fakedom.mjs'); + installFakeDom(); + const { inspect } = await import('../js/inspector.js'); + const box = new FakeEl('div'); + let asked = null; + globalThis.fetch = async url => { asked = url; return { ok: true, status: 200, json: async () => ({ + lat: 1, lon: 2, cell: '84', era: { name: 'high-fantasy', label: 'High Fantasy' }, fields: [] }) }; }; + await inspect(box, { lat: 1, lon: 2 }, 'high-fantasy'); + assert.match(asked, /&era=high-fantasy$/); + assert.match(box.textContent, /High Fantasy/); +}); diff --git a/viewer/tests/latest.test.mjs b/viewer/tests/latest.test.mjs new file mode 100644 index 0000000..e257ebf --- /dev/null +++ b/viewer/tests/latest.test.mjs @@ -0,0 +1,24 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { Latest } = await import('../js/latest.js'); +const { metaFor } = await import('../js/boot.js'); + +test('only the newest call is current: an older answer arriving late is dropped', () => { + const l = new Latest(); + const a = l.next(), b = l.next(); + assert.equal(l.is(a), false); + assert.equal(l.is(b), true); +}); + +test('startup meta: an unknown era falls back to the default with a note', async () => { + const get = async u => { if (u.includes('era=')) throw new Error('unknown era'); return { era: 'hf' }; }; + assert.deepEqual(await metaFor(get, 'x'), { meta: { era: 'hf' }, note: 'Era “x”: unknown era — showing hf' }); + assert.deepEqual(await metaFor(get, ''), { meta: { era: 'hf' }, note: '' }); +}); + +test('startup meta: without an era a failure is not called an unknown era', async () => { + const get = async () => { throw new Error('502 Bad Gateway'); }; + await assert.rejects(metaFor(get, ''), /502/); +}); diff --git a/viewer/tests/layers.test.mjs b/viewer/tests/layers.test.mjs new file mode 100644 index 0000000..229606b --- /dev/null +++ b/viewer/tests/layers.test.mjs @@ -0,0 +1,50 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import './importmap.mjs'; +import { FakeEl, installFakeDom } from './fakedom.mjs'; + +installFakeDom(); +const { renderLegend } = await import('../js/layers.js'); + +test('legend shows readable units (degC → °C)', () => { + const box = new FakeEl('div'); + renderLegend(box, { name: 'T', legend: { type: 'continuous', unit: 'degC', stops: [[-40, '#000000'], [40, '#ffffff']] } }); + assert.match(box.textContent, /°C/); + assert.doesNotMatch(box.textContent, /degC/); +}); + +test('the base and over-layer textures in use are never evicted', async () => { + const { LayerStore } = await import('../js/layers.js'); + const ids = ['relief', 'biomes', 'a', 'b', 'c', 'd'], disposed = []; + const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } })); + store.pin(['relief', 'biomes']); + for (const id of ids) await store.texture(id); + await new Promise(r => setTimeout(r, 0)); + assert.ok(!disposed.includes('relief') && !disposed.includes('biomes'), disposed.join()); + assert.ok(disposed.includes('a')); +}); + +test('textures still on screen are never evicted while newer ones load', async () => { + const { LayerStore } = await import('../js/layers.js'); + const ids = ['relief', 'a', 'b', 'c', 'd', 'e'], disposed = []; + const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } })); + await store.texture('relief'); + store.showing(['relief']); // drawn now + for (const id of ids.slice(1)) { store.pin([id]); store.texture(id); } // rapid layer switches, none shown yet + await new Promise(r => setTimeout(r, 0)); + assert.ok(!disposed.includes('relief'), disposed.join()); +}); + +test('dispose frees every cached texture except those still drawn (another era took over)', async () => { + const { LayerStore } = await import('../js/layers.js'); + const ids = ['relief', 'a', 'b'], disposed = []; + const store = new LayerStore(ids.map(id => ({ id, file: id })), 8192, 1, async l => ({ id: l.id, dispose() { disposed.push(l.id); } })); + const drawn = await store.texture('relief'); + await store.texture('a'); + await store.texture('b'); + store.dispose([drawn]); + await new Promise(r => setTimeout(r, 0)); + assert.deepEqual(disposed.sort(), ['a', 'b']); + assert.equal(store.cache.size, 0); +}); diff --git a/viewer/tests/layout.test.mjs b/viewer/tests/layout.test.mjs new file mode 100644 index 0000000..14fb8d6 --- /dev/null +++ b/viewer/tests/layout.test.mjs @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { compactMode, freeArea, applyMode, textureCap } = await import('../js/layout.js'); + +test('phones get a compact layout; tablets and desktops keep theirs', () => { + assert.equal(compactMode(390, 844), 'portrait'); // phone, upright + assert.equal(compactMode(700, 1000), 'portrait'); + assert.equal(compactMode(844, 390), 'landscape'); // phone on its side + assert.equal(compactMode(1000, 500), 'landscape'); + assert.equal(compactMode(1024, 768), ''); // tablet + assert.equal(compactMode(1400, 900), ''); + assert.equal(compactMode(1200, 450), '', 'a wide short window is a desktop'); +}); + +test('the free map area leaves out what the panel covers', () => { + assert.deepEqual(freeArea(1400, 900, { left: 1050, top: 10, width: 340, height: 880 }), { w: 1050, h: 900 }); + assert.deepEqual(freeArea(390, 844, { left: 0, top: 464, width: 390, height: 380 }), { w: 390, h: 464 }); + assert.deepEqual(freeArea(390, 844, null), { w: 390, h: 844 }); + assert.deepEqual(freeArea(390, 844, { left: 0, top: 0, width: 0, height: 0 }), { w: 390, h: 844 }); + assert.deepEqual(freeArea(390, 844, { left: 0, top: 10, width: 390, height: 834 }), { w: 390, h: 64 }, 'never below a tile row'); +}); + +test('applyMode sets the body classes and says when a phone layout begins', () => { + const cls = new Set(['menu-open']); + const body = { classList: { toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), remove: c => cls.delete(c) } }; + assert.equal(applyMode(body, 'portrait', ''), true); + assert.deepEqual([...cls].sort(), ['compact', 'menu-open', 'portrait']); + assert.equal(applyMode(body, 'landscape', 'portrait'), false, 'already compact'); + assert.deepEqual([...cls].sort(), ['compact', 'landscape', 'menu-open']); + assert.equal(applyMode(body, '', 'landscape'), false); + assert.deepEqual([...cls], [], 'desktop: no phone classes, menu closed'); +}); + +test('touch devices and phone screens get 4096-px layer textures; desktops their GPU maximum', () => { + assert.equal(textureCap(16384, false, 1400, 900), 16384); + assert.equal(textureCap(16384, true, 1400, 900), 4096, 'a tablet'); + assert.equal(textureCap(16384, false, 390, 844), 4096, 'a phone-sized window'); + assert.equal(textureCap(2048, true, 390, 844), 2048, 'never above the GPU limit'); +}); diff --git a/viewer/tests/measure.test.mjs b/viewer/tests/measure.test.mjs new file mode 100644 index 0000000..f8c2110 --- /dev/null +++ b/viewer/tests/measure.test.mjs @@ -0,0 +1,82 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MeasurePath } from '../js/measure.js'; +import { R_KM } from '../js/geo.js'; +import { FakeEl, installFakeDom } from './fakedom.mjs'; + +const A = { lat: 0, lon: 0 }, B = { lat: 0, lon: 90 }, C = { lat: 90, lon: 0 }; +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('open path: segments and total', () => { + const m = new MeasurePath(); + [A, B, C].forEach(p => m.add(p)); + assert.equal(m.segmentsKm().length, 2); + near(m.totalKm(), R_KM * Math.PI, 1e-6); // two quarter circles + assert.equal(m.areaKm2(), null); + assert.equal(m.lines()[0].length, 3); +}); + +test('closing needs three points; a closed path adds the return leg and an area', () => { + const m = new MeasurePath(); + m.add(A); + m.add(B); + assert.equal(m.close(), false); + m.add(C); + assert.equal(m.close(), true); + assert.equal(m.segmentsKm().length, 3); + near(m.areaKm2(), (4 * Math.PI * R_KM ** 2) / 8, 1); + assert.deepEqual(m.lines()[0].at(-1), A); +}); + +test('undo reopens then removes; repeats ignored; adding after a close starts over', () => { + const m = new MeasurePath(); + [A, B, C].forEach(p => m.add(p)); + m.close(); + m.undo(); + assert.equal(m.closed, false); + assert.equal(m.points.length, 3); + m.undo(); + assert.equal(m.points.length, 2); + m.add(B); // a double-click lands twice on the same spot + assert.equal(m.points.length, 2); + m.add(C); + m.close(); + m.add(A); + assert.deepEqual(m.points, [A]); +}); + +test('panel never renders null/undefined children (DOM would print "null")', async () => { + installFakeDom(); + const { renderMeasure } = await import('../js/measure.js'); + const box = new FakeEl('section'), noop = () => {}; + const m = new MeasurePath(); + for (const pts of [[], [A], [A, B]]) { + m.clear(); + pts.forEach(p => m.add(p)); + renderMeasure(box, m, { undo: noop, close: noop, clear: noop }); + assert.ok(box.kids.every(k => k !== null && k !== undefined), `${pts.length} points: ${box.kids.map(String)}`); + } +}); + +test('elevation chart: an svg profile; hovering reports the nearest sample', async () => { + installFakeDom(); + const { renderProfile, renderMeasure } = await import('../js/measure.js'); + const box = new FakeEl('div'), seen = []; + const prof = { samples: [{ d_km: 0, lat: 0, lon: 0, z_m: -100, water: true }, { d_km: 10, lat: 0, lon: 0.1, z_m: 500, water: false }], + stats: { min_m: -100, max_m: 500, ascent_m: 600, descent_m: 0, water_km: 5, length_km: 10 } }; + renderProfile(box, prof, p => seen.push(p)); + const chart = box.kids[0]; + assert.equal(chart.tag, 'svg'); + assert.ok(chart.find(e => e.tag === 'path')); + chart.fire('pointermove', { clientX: 300 }); + assert.equal(seen.at(-1).d_km, 10); + assert.match(box.textContent, /Highest 500 m/); + chart.fire('pointerleave'); + assert.equal(seen.at(-1), null); + const mbox = new FakeEl('section'), m = new MeasurePath(); + m.add(A); + m.add(B); + const pbox = renderMeasure(mbox, m, { undo() {}, close() {}, clear() {} }); + assert.ok(mbox.kids.includes(pbox)); +}); diff --git a/viewer/tests/modules.test.mjs b/viewer/tests/modules.test.mjs new file mode 100644 index 0000000..8565085 --- /dev/null +++ b/viewer/tests/modules.test.mjs @@ -0,0 +1,23 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { HTML, IMPORTS } from './importmap.mjs'; + +const JS = new URL('../js/', import.meta.url); +const files = readdirSync(JS).filter(f => f.endsWith('.js')); + +test('import map targets exist', () => { + for (const v of Object.values(IMPORTS)) assert.ok(existsSync(new URL(v, new URL('../', import.meta.url))), v); +}); + +test('every viewer module links (imports resolve, named exports exist)', async () => { + for (const f of files) await import(new URL(f, JS).href); +}); + +test('every #id the scripts look up exists in index.html', () => { + const ids = new Set([...HTML.matchAll(/\bid="([\w-]+)"/g)].map(m => m[1])); + for (const f of files) { + for (const m of readFileSync(new URL(f, JS), 'utf8').matchAll(/\$\('#([\w-]+)'\)/g)) assert.ok(ids.has(m[1]), `${f}: #${m[1]}`); + } +}); diff --git a/viewer/tests/nav.test.mjs b/viewer/tests/nav.test.mjs new file mode 100644 index 0000000..4efde7f --- /dev/null +++ b/viewer/tests/nav.test.mjs @@ -0,0 +1,64 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { keyAction, exportName } from '../js/nav.js'; + +const k = (key, extra = {}) => keyAction({ key, target: { tagName: 'CANVAS' }, ...extra }); + +test('keys map to actions', () => { + assert.deepEqual(k('ArrowLeft'), ['pan', -0.2, 0]); + assert.deepEqual(k('ArrowDown'), ['pan', 0, 0.2]); + assert.deepEqual(k('+'), ['zoom', 0.6]); + assert.deepEqual(k('='), ['zoom', 0.6]); + assert.deepEqual(k('-'), ['zoom', 1 / 0.6]); + assert.deepEqual(k('Home'), ['home']); + assert.deepEqual(k('f'), ['fullscreen']); + assert.deepEqual(k('/'), ['search']); + assert.equal(k('x'), null); +}); + +test('typing and modifier chords are left alone', () => { + for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT']) assert.equal(keyAction({ key: 'ArrowLeft', target: { tagName } }), null); + assert.equal(k('f', { ctrlKey: true }), null); + assert.equal(k('ArrowUp', { metaKey: true }), null); + assert.equal(k('-', { altKey: true }), null); +}); + +test('export file names', () => { + assert.equal(exportName({ layer: 'relief', over: '', view: 'globe' }), 'world-relief-globe.png'); + assert.equal(exportName({ layer: 'relief', over: 'biomes', view: 'equal_earth' }), 'world-relief+biomes-equal_earth.png'); +}); + +test('exports note procedural detail when tiles are showing', async () => { + const { exportNotes } = await import('../js/nav.js'); + assert.deepEqual(exportNotes({ view: { tiles: { active: true } } }), ['procedural detail — not data']); + assert.deepEqual(exportNotes({ view: { tiles: { active: false } } }), []); + assert.deepEqual(exportNotes({ view: {} }), []); +}); + +test('Shift+arrows turn and tilt; plain arrows still pan; N points north', () => { + const k = (key, o = {}) => keyAction({ key, target: { tagName: 'CANVAS' }, ...o }); + assert.deepEqual(k('ArrowLeft', { shiftKey: true }), ['turn', -15, 0]); + assert.deepEqual(k('ArrowUp', { shiftKey: true }), ['turn', 0, 10]); + assert.deepEqual(k('ArrowLeft'), ['pan', -0.2, 0]); + assert.deepEqual(k('n'), ['north']); + assert.equal(k('n', { target: { tagName: 'INPUT' } }), null); +}); + +test('G goes to ground view, ? shows the controls', () => { + assert.deepEqual(k('g'), ['ground']); + assert.deepEqual(k('?', { shiftKey: true }), ['help']); + assert.equal(k('g', { target: { tagName: 'INPUT' } }), null); +}); + +test('ground view: the eye above the spot looked at, close and tilted, heading kept', async () => { + const { groundPose, GROUND_RANGE, GROUND_PITCH } = await import('../js/nav.js'); + const ahead = GROUND_RANGE * Math.sin(GROUND_PITCH * Math.PI / 180) * 180 / Math.PI; // degrees of arc + const n = groundPose({ lat: 0, lon: 10, range: 0.5, heading: 0, pitch: 0 }); + assert.ok(Math.abs(n.lat - ahead) < 1e-9 && Math.abs(n.lon - 10) < 1e-9, 'looking north: the target lies ahead'); + assert.equal(n.range, GROUND_RANGE); + assert.equal(n.pitch, GROUND_PITCH); + const e = groundPose({ lat: 0, lon: 10, range: 0.5, heading: 90, pitch: 0 }); + assert.ok(Math.abs(e.lat) < 1e-9 && Math.abs(e.lon - 10 - ahead) < 1e-9 && e.heading === 90); + assert.ok(GROUND_RANGE * 12742 < 5 && GROUND_PITCH >= 70); +}); diff --git a/viewer/tests/overlay_geom.test.mjs b/viewer/tests/overlay_geom.test.mjs new file mode 100644 index 0000000..c4c6c6e --- /dev/null +++ b/viewer/tests/overlay_geom.test.mjs @@ -0,0 +1,69 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as O from '../js/overlay_geom.js'; +import { isClick } from '../js/input.js'; + +const fc = { type: 'FeatureCollection', features: [ + { type: 'Feature', properties: { order: 4 }, geometry: { type: 'LineString', coordinates: [[0, 0], [10, 0]] } }, + { type: 'Feature', properties: { order: 1 }, geometry: { type: 'LineString', coordinates: [[0, 5], [1, 5]] } }, + { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]], [[0.2, 0.2], [0.4, 0.2], [0.2, 0.4], [0.2, 0.2]]] } }, + { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: [[[[5, 5], [6, 5], [6, 6], [5, 5]]]] } }, +] }; + +test('linesFromGeoJSON reads all geometry kinds and filters', () => { + assert.equal(O.linesFromGeoJSON(fc).length, 5); + const big = O.linesFromGeoJSON(fc, p => (p.order ?? 0) >= 3); + assert.equal(big.length, 1); + assert.deepEqual(big[0][1], { lat: 0, lon: 10 }); +}); + +test('polygon rings are split where they run along the antimeridian cut', () => { + const cut = { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', + coordinates: [[[170, 0], [180, 0], [180, 10], [170, 10], [170, 0]]] } }] }; + const lines = O.linesFromGeoJSON(cut); + assert.equal(lines.length, 2); + for (const l of lines) for (let i = 0; i + 1 < l.length; i++) + assert.ok(!(Math.abs(l[i].lon) === 180 && Math.abs(l[i + 1].lon) === 180), 'no segment along lon ±180'); +}); + +test('globe segments lie just above the unit sphere and follow great circles', () => { + const pos = O.globeSegments([[{ lat: 0, lon: 0 }, { lat: 0, lon: 10 }]], 1.002, 1); + assert.equal(pos.length, 10 * 6); + for (let i = 0; i < pos.length; i += 3) assert.ok(Math.abs(Math.hypot(pos[i], pos[i + 1], pos[i + 2]) - 1.002) < 1e-6); +}); + +test('flat segments drop antimeridian jumps', () => { + for (const view of ['equal_earth', 'plate_carree']) { + assert.equal(O.flatSegments([[{ lat: 10, lon: 179 }, { lat: 10, lon: -179 }]], view).length, 0); + assert.equal(O.flatSegments([[{ lat: 10, lon: 170 }, { lat: 10, lon: 179 }]], view).length, 6); + } +}); + +test('densify and graticule', () => { + const d = O.densify([[{ lat: 0, lon: 0 }, { lat: 0, lon: 3 }, { lat: 0, lon: 5 }]], 1); + assert.equal(d[0].length, 6); + assert.equal(O.graticuleLines(30).length, 13 + 5); +}); + +test('clicks vs drags', () => { + assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 12, y: 11, t: 200 }), true); + assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 40, y: 10, t: 200 }), false); + assert.equal(isClick({ x: 10, y: 10, t: 0 }, { x: 10, y: 10, t: 2000 }), false); +}); + +test('drapedSegments: lines near the camera lie on the ground, finer close up; far ones are skipped', async () => { + const { drapedSegments } = await import('../js/overlay_geom.js'); + const { toVec, R_KM } = await import('../js/geo.js'); + const R_M = R_KM * 1000, eyeDir = toVec(40, -10), eye = eyeDir.map(v => v * (1 + 1500 / R_M)); // above the ground + const opts = { eye, n: eyeDir, cosCap: Math.cos(0.01), origin: eyeDir, heightM: lat => (lat > 40 ? 1000 : 200) }; + const near = [[{ lat: 40.001, lon: -10 }, { lat: 40.03, lon: -10 }]]; // ≈ 200 m … 4 km from the camera + const far = [[{ lat: 60, lon: 50 }, { lat: 61, lon: 50 }]]; + const p = drapedSegments([...near, ...far], opts); + assert.ok(p.length / 6 > 10, `near segment cut into pieces: ${p.length / 6}`); + for (let i = 0; i < p.length; i += 3) { + const r = Math.hypot(p[i] + eyeDir[0], p[i + 1] + eyeDir[1], p[i + 2] + eyeDir[2]); + assert.ok(r > 1 + 1000 / R_M && r < 1 + 1020 / R_M, `vertex ${i / 3} at ${(r - 1) * R_M} m`); // ground + 1 m, pulled ≤ 1% up + } + assert.equal(drapedSegments(far, opts).length, 0); +}); diff --git a/viewer/tests/overlays.test.mjs b/viewer/tests/overlays.test.mjs new file mode 100644 index 0000000..d6f3d7c --- /dev/null +++ b/viewer/tests/overlays.test.mjs @@ -0,0 +1,147 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const fc = { type: 'FeatureCollection', features: [ + { type: 'Feature', properties: { order: 1 }, geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] } }, + { type: 'Feature', properties: { order: 4 }, geometry: { type: 'LineString', coordinates: [[5, 5], [6, 6]] } }, +] }; +let fetches = 0; +globalThis.fetch = async () => { fetches++; return new Response(JSON.stringify(fc), { status: 200 }); }; +const { Overlays } = await import('../js/overlays.js'); + +const flush = () => new Promise(r => setTimeout(r, 0)); +const box = { replaceChildren() {} }; +const defs = [{ id: 'coast', name: 'Coastline', file: '/data/geo/coast.geojson' }, + { id: 'rivers', name: 'Rivers', file: '/data/geo/rivers.geojson' }]; +const fakeView = () => ({ lines: new Map(), setLines(id, l) { this.lines.set(id, l); }, + removeLines(id) { this.lines.delete(id); }, toScreen: () => null }); + +test('enabled overlays reach the view; rivers show the orders that stay apart at the scale', async () => { + const o = new Overlays(defs, box), v = fakeView(); + o.setEnabled(['coast', 'rivers', 'graticule']); + o.attach(v); + await flush(); + assert.deepEqual([...v.lines.keys()].sort(), ['coast', 'graticule', 'rivers']); + assert.equal(v.lines.get('rivers').length, 1); // no scale yet: the largest order only + o.setScale(0.01); // a first-order segment (≈ 157 km) spans far more than 8 px + await flush(); + assert.equal(v.lines.get('rivers').length, 2); + o.setScale(1000); + await flush(); + assert.equal(v.lines.get('rivers').length, 1); + assert.equal(v.lines.get('graticule').length, 18); + o.setEnabled(['coast']); + await flush(); + assert.deepEqual([...v.lines.keys()], ['coast']); +}); + +test('GeoJSON fetched once; a newly attached view gets everything; extras always shown', async () => { + fetches = 0; + const o = new Overlays(defs, box), a = fakeView(), b = fakeView(); + o.setEnabled(['coast']); + o.attach(a); + await flush(); + o.setExtra('measure', [[{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }]]); + o.attach(b); + await flush(); + assert.equal(fetches, 1); + assert.deepEqual([...b.lines.keys()].sort(), ['coast', 'measure']); + o.setExtra('measure', []); + assert.deepEqual(b.lines.get('measure'), []); +}); + +test('list offers meta overlays plus graticule, refined regions, vents and pins', () => { + assert.deepEqual(new Overlays(defs, box).list().map(o => o.id), ['coast', 'rivers', 'graticule', 'regions', 'vents', 'pins']); +}); + +test('vent markers carry a title and class, show only with their overlay, and restyle when re-clustered', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + const mbox = new FakeEl('div'), o = new Overlays(defs, mbox); + o.attach({ ...fakeView(), toScreen: () => ({ x: 1, y: 2 }) }); + o.setMarkers('vent', [{ lat: 1, lon: 2, label: '', cls: 't0', title: 'black smoker' }]); + const [m] = mbox.kids; + assert.match(m.className, /marker vent t0/); + assert.equal(m.attrs.title, 'black smoker'); + assert.equal(m.style.display, 'none', 'vents overlay off'); + o.setEnabled(['vents']); + assert.equal(m.style.display, ''); + o.setMarkers('vent', [{ lat: 1, lon: 2, label: '', cls: 't2', title: 'diffuse' }]); + assert.match(mbox.kids[0].className, /t2/); +}); + +test('refined-region outlines draw while their overlay is on', () => { + const o = new Overlays(defs, box), v = fakeView(); + o.attach(v); + const line = [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 1, lon: 2 }]; + o.setRegions([line]); + assert.ok(!v.lines.has('regions'), 'off: nothing drawn'); + o.setEnabled(['regions']); + assert.deepEqual(v.lines.get('regions'), [line]); + o.setEnabled([]); + assert.ok(!v.lines.has('regions')); +}); + +test('asks the app to redraw after lines change, including after async loads', async () => { + let n = 0; + const o = new Overlays(defs, box, () => n++), v = fakeView(); + o.setEnabled(['coast']); + o.attach(v); + const before = n; + await flush(); + assert.ok(v.lines.has('coast')); + assert.ok(n > before, 'redraw requested after the GeoJSON arrived'); + const k = n; + o.setEnabled([]); + assert.ok(n > k, 'redraw requested after removal'); +}); + +test('markers with onClick are clickable; the rest let the pointer through', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + let hit = null; + const mbox = new FakeEl('div'), o = new Overlays(defs, mbox); + o.attach(fakeView()); + o.setMarkers('pin', [{ lat: 1, lon: 2, label: 'A', onClick: () => { hit = 'A'; } }]); + o.setMarkers('measure', [{ lat: 3, lon: 4, label: '1' }]); + const [pin, meas] = mbox.kids; + assert.match(pin.className, /clickable/); + assert.doesNotMatch(meas.className, /clickable/); + pin.fire('click'); + assert.equal(hit, 'A'); +}); + +test('suppressed overlays leave the view and come back', async () => { + const o = new Overlays(defs, box), v = fakeView(); + o.setEnabled(['coast']); + o.attach(v); + await flush(); + o.setSuppressed(['coast', 'lakes']); + assert.equal(v.lines.has('coast'), false); + o.setSuppressed([]); + await flush(); + assert.equal(v.lines.has('coast'), true); +}); + +test('moving a marker keeps its DOM node (sky play moves the sun 20× a second)', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + const mbox = new FakeEl('div'), o = new Overlays(defs, mbox); + o.attach(fakeView()); + o.setMarkers('sun', [{ lat: 1, lon: 2, label: '☉' }]); + const node = mbox.kids[0]; + o.setMarkers('sun', [{ lat: 3, lon: 4, label: '☉' }]); + assert.equal(mbox.kids[0], node); + assert.deepEqual([o.markers[0].lat, o.markers[0].lon], [3, 4]); +}); + +test('new definitions drop the loaded overlays (another era)', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + const o = new Overlays([{ id: 'coast', name: 'Coast', file: '/data/geo/coast.geojson?era=a' }], new FakeEl('div')); + o.cache.set('coast', Promise.resolve([])); + o.setDefs([{ id: 'coast', name: 'Coast', file: '/data/geo/coast.geojson?era=b' }]); + assert.equal(o.cache.size, 0); + assert.match(o.defs[0].file, /era=b$/); +}); diff --git a/viewer/tests/pins.test.mjs b/viewer/tests/pins.test.mjs new file mode 100644 index 0000000..1d69015 --- /dev/null +++ b/viewer/tests/pins.test.mjs @@ -0,0 +1,51 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { filterPins, loreWarning, pinPayload, epochIds } from '../js/pins.js'; + +const pins = [ + { id: 'pin-1', name: 'Zeta ford', lat: 1, lon: 2, lore: null, note: 'shallow crossing' }, + { id: 'pin-2', name: 'alpha peak', lat: 3, lon: 4, lore: 'physical-world', note: '' }, + { id: 'pin-3', name: 'Mid Lake', lat: 5, lon: 6, lore: null, note: '' }, +]; + +test('search: name, lore id and note, case-insensitive; sorted by name', () => { + assert.deepEqual(filterPins(pins, '').map(p => p.id), ['pin-2', 'pin-3', 'pin-1']); + assert.deepEqual(filterPins(pins, 'PEAK').map(p => p.id), ['pin-2']); + assert.deepEqual(filterPins(pins, 'physical').map(p => p.id), ['pin-2']); + assert.deepEqual(filterPins(pins, 'crossing').map(p => p.id), ['pin-1']); + assert.deepEqual(filterPins(pins, 'nothing'), []); + assert.equal(pins[0].id, 'pin-1', 'input not reordered'); +}); + +test('lore warning only for unknown ids', () => { + assert.equal(loreWarning('', ['a']), null); + assert.equal(loreWarning('a', ['a']), null); + assert.match(loreWarning('b-c', ['a']), /not a known lore id/); +}); + +test('payload trims and turns an empty lore id into null', () => { + assert.deepEqual(pinPayload({ name: ' Ford ', lat: 1, lon: 2, lore: ' ', note: ' x ', id: 'pin-9', cell: 'c' }), + { name: 'Ford', lat: 1, lon: 2, lore: null, note: 'x', epoch: null }); +}); + +test('a draft marker shows while the new-pin form is open', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + const { PinsPanel } = await import('../js/pins.js'); + const drafts = []; + const p = new PinsPanel(new FakeEl('section'), [], { onPins() {}, onFly() {}, onPlacing() {}, onDraft: d => drafts.push(d) }); + p.placeAt({ lat: 1, lon: 2 }); + assert.deepEqual(drafts.at(-1), { lat: 1, lon: 2 }); + p.box.find(e => e.tag === 'button' && e.textContent === 'Cancel').fire('click'); + assert.equal(drafts.at(-1), null); +}); + +test('payload carries the epoch and numeric coordinates', () => { + assert.deepEqual(pinPayload({ name: 'X', lat: '-15.5', lon: '-34.2', lore: 'cell-am-zee', note: '', epoch: 'epoch-high-fantasy' }), + { name: 'X', lat: -15.5, lon: -34.2, lore: 'cell-am-zee', note: '', epoch: 'epoch-high-fantasy' }); +}); + +test('epoch choices come from the lore ids', () => { + assert.deepEqual(epochIds(['a', 'epoch-high-fantasy', 'epochs', 'epoch-space-age']), ['epoch-high-fantasy', 'epoch-space-age']); +}); diff --git a/viewer/tests/profile.test.mjs b/viewer/tests/profile.test.mjs new file mode 100644 index 0000000..9418565 --- /dev/null +++ b/viewer/tests/profile.test.mjs @@ -0,0 +1,36 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { profileModel, sampleAt } from '../js/profile.js'; + +const S = [{ d_km: 0, z_m: -200, water: true }, { d_km: 50, z_m: 300, water: false }, { d_km: 100, z_m: 1200, water: false }, + { d_km: 150, z_m: -50, water: true }]; + +test('model spans distance and elevation incl. sea level', () => { + const m = profileModel(S, 320, 130); + assert.ok(m.line.startsWith('M')); + assert.equal(m.L, 150); + assert.ok(m.lo <= -200 && m.hi >= 1200); + assert.ok(m.Y(0) > m.Y(1200) && m.Y(0) < m.Y(-200)); + assert.ok(Math.abs(m.xAt(m.X(75)) - 75) < 1e-9); + assert.equal(m.seaY, m.Y(0)); +}); + +test('water runs cover the water samples, half a step either side', () => { + const m = profileModel(S, 320, 130); + assert.equal(m.water.length, 2); + assert.ok(Math.abs(m.water[0][0] - m.X(0)) < 1e-9 && Math.abs(m.water[0][1] - m.X(25)) < 1e-9); + assert.ok(Math.abs(m.water[1][0] - m.X(125)) < 1e-9 && Math.abs(m.water[1][1] - m.X(150)) < 1e-9); +}); + +test('flat land still gets a usable vertical range', () => { + const m = profileModel([{ d_km: 0, z_m: 10, water: false }, { d_km: 5, z_m: 10, water: false }], 320, 130); + assert.ok(m.hi - m.lo >= 100); +}); + +test('nearest sample by distance', () => { + assert.equal(sampleAt(S, -5), 0); + assert.equal(sampleAt(S, 74), 1); + assert.equal(sampleAt(S, 76), 2); + assert.equal(sampleAt(S, 999), 3); +}); diff --git a/viewer/tests/public.test.mjs b/viewer/tests/public.test.mjs new file mode 100644 index 0000000..04ded8c --- /dev/null +++ b/viewer/tests/public.test.mjs @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { hiddenTabs, hideTabs } = await import('../js/public.js'); + +test('a read-only map hides the editing tabs; a private one hides none', () => { + assert.deepEqual(hiddenTabs({ read_only: true }), ['pins', 'regions', 'export']); + assert.deepEqual(hiddenTabs({ read_only: false }), []); + assert.deepEqual(hiddenTabs({}), []); + assert.deepEqual(hiddenTabs(null), []); +}); + +test('hideTabs removes the buttons and sections it names', () => { + const removed = []; + const doc = { querySelector: sel => ({ remove: () => removed.push(sel) }) }; + hideTabs(doc, ['pins']); + assert.deepEqual(removed, ['#panel nav [data-tab="pins"]', '#tab-pins']); +}); diff --git a/viewer/tests/regions.test.mjs b/viewer/tests/regions.test.mjs new file mode 100644 index 0000000..d7a9d65 --- /dev/null +++ b/viewer/tests/regions.test.mjs @@ -0,0 +1,82 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { closesOutline, crossesItself, regionPayload, statusText, outlineLines } from '../js/regions.js'; + +test('clicking near the first point closes the outline', () => { + assert.equal(closesOutline({ x: 100, y: 100 }, { x: 108, y: 105 }), true); + assert.equal(closesOutline({ x: 100, y: 100 }, { x: 130, y: 100 }), false); + assert.equal(closesOutline(null, { x: 1, y: 1 }), false); +}); + +test('self-crossing outlines are refused; unwraps across ±180°', () => { + const sq = [[0, 0], [0, 2], [2, 2], [2, 0]].map(([lat, lon]) => ({ lat, lon })); + const bow = [[0, 0], [2, 2], [0, 2], [2, 0]].map(([lat, lon]) => ({ lat, lon })); + assert.equal(crossesItself(sq), false); + assert.equal(crossesItself(bow), true); + const dateline = [[0, 179], [0, -179], [2, -179], [2, 179]].map(([lat, lon]) => ({ lat, lon })); + assert.equal(crossesItself(dateline), false); +}); + +test('payload, status text, outline lines', () => { + assert.deepEqual(regionPayload(' Gorge ', [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }]), + { name: 'Gorge', outline: [[1, 2], [3, 4], [5, 6]] }); + assert.equal(statusText({ state: 'building', stage: 'erosion', fraction: 0.456 }), 'Refining… 46% (erosion)'); + assert.equal(statusText({ state: 'ready' }), 'Refined areas are up to date'); + assert.match(statusText({ state: 'failed', error: 'boom' }), /failed.*boom/i); + assert.equal(statusText({ state: 'queued' }), 'Queued…'); + const lines = outlineLines([{ outline: [[1, 2], [3, 4], [5, 6]] }]); + assert.equal(lines.length, 1); + assert.deepEqual(lines[0].at(-1), { lat: 1, lon: 2 }, 'closed'); +}); + +test('status shows the tiles being saved ahead after a build', async () => { + const { statusText } = await import('../js/regions.js'); + assert.equal(statusText({ state: 'ready', tiles: { done: 45, total: 100 } }), 'Refined areas are up to date · saving tiles 45%'); + assert.equal(statusText({ state: 'ready', tiles: { done: 100, total: 100 } }), 'Refined areas are up to date'); + assert.equal(statusText({ state: 'idle', tiles: { done: 3, total: 10 } }), 'Saving region tiles 30%'); +}); + +test('outline area on a 2× Earth-radius world, also across ±180°', async () => { + const { outlineAreaKm2 } = await import('../js/regions.js'); + const box = (la0, lo0, d) => [{ lat: la0, lon: lo0 }, { lat: la0, lon: lo0 + d }, { lat: la0 + d, lon: lo0 + d }, { lat: la0 + d, lon: lo0 }]; + const want = 12742 ** 2 * (10 * Math.PI / 180) * Math.sin(10 * Math.PI / 180); + assert.ok(Math.abs(outlineAreaKm2(box(0, 20, 10)) / want - 1) < 1e-9); + assert.ok(Math.abs(outlineAreaKm2(box(0, 175, 10).map(p => ({ ...p, lon: p.lon > 180 ? p.lon - 360 : p.lon }))) / want - 1) < 1e-9); + assert.ok(Math.abs(outlineAreaKm2([...box(0, 20, 10)].reverse()) / want - 1) < 1e-9, 'either winding'); +}); + +test('tiles switch whenever the server names a different URL', async () => { + const { needsTileSwitch } = await import('../js/regions.js'); + assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, { url: '/tiles/b/x' }), true); + assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, { url: '/tiles/a/x' }), false); + assert.equal(needsTileSwitch({ url: '/tiles/a/x' }, undefined), false); +}); + +test('outline clicks: add, close on the first point, and a double-click (a second click on the last point) closes', async () => { + const { outlineClick } = await import('../js/regions.js'); + const first = { x: 100, y: 100 }, last = { x: 300, y: 200 }; + assert.equal(outlineClick(null, null, { x: 5, y: 5 }, 0), 'add'); + assert.equal(outlineClick(first, last, { x: 400, y: 400 }, 3), 'add'); + assert.equal(outlineClick(first, last, { x: 103, y: 99 }, 3), 'close'); + assert.equal(outlineClick(first, last, { x: 301, y: 202 }, 3), 'close', 'double-click on the last point'); + assert.equal(outlineClick(first, last, { x: 301, y: 202 }, 2), 'ignore', 'too few points: no duplicate either'); +}); + +test('a poll tells which era it asked about: an answer for the era before a switch is not taken for this one', async () => { + const { FakeEl, installFakeDom } = await import('./fakedom.mjs'); + installFakeDom(); + const { RegionsPanel } = await import('../js/regionspanel.js'); + let q = '?era=a', release; + const seen = []; + globalThis.fetch = () => new Promise(r => { release = () => r({ ok: true, status: 200, + json: async () => ({ regions: [], status: { state: 'ready' }, tiles: { url: 'a' } }) }); }); + const p = new RegionsPanel(new FakeEl('section'), { onDraft() {}, onDrawing() {}, onRegions() {}, + onStatus: (s, t, was, asked) => seen.push(asked), eraQuery: () => q }); + const done = p.poll(); + q = '?era=b'; + release(); + await done; + clearTimeout(p.timer); + assert.deepEqual(seen, ['?era=a']); +}); diff --git a/viewer/tests/riverlod.test.mjs b/viewer/tests/riverlod.test.mjs new file mode 100644 index 0000000..6bc58cc --- /dev/null +++ b/viewer/tests/riverlod.test.mjs @@ -0,0 +1,37 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { riverStats, riverMinOrder, cellSpacingKm } = await import('../js/riverlod.js'); +// r5 reference world: total river length per Strahler order (km, from its rivers.geojson) and its cell spacing +const r5 = { length: { 1: 3177232, 2: 430077, 3: 141166, 4: 38607, 5: 8174, 6: 1273 } }; +const cell = cellSpacingKm(5, 6371); + +test('H3 res-5 cells on an Earth-sized planet lie ≈ 16 km apart', () => { + assert.ok(Math.abs(cell - 15.9) < 0.2, String(cell)); +}); + +test('a phone at 26 km/px (the view that merged into blue) draws orders ≥ 3', () => { + assert.equal(riverMinOrder(26, r5, cell), 3); // order 3 ≈ 358 km apart ≥ 208; order 2 ≈ 117 km is not +}); + +test('closer in, smaller rivers join; first-order ones only when a cell spans ≥ 8 px', () => { + assert.equal(riverMinOrder(14, r5, cell), 2); + assert.equal(riverMinOrder(cell / 8, r5, cell), 1); + assert.equal(riverMinOrder(cell / 7, r5, cell), 2); +}); + +test('far out: only the largest rivers; no scale yet: the largest; no data: all', () => { + assert.equal(riverMinOrder(1e4, r5, cell), 6); + assert.equal(riverMinOrder(null, r5, cell), 6); + assert.equal(riverMinOrder(5, { length: {} }, cell), 1); +}); + +test('riverStats sums each order\'s length', async () => { + const line = (o, ...lons) => ({ properties: { order: o }, geometry: { coordinates: lons.map(x => [x, 0]) } }); + const s = riverStats({ features: [line(1, 0, 1), line(1, 2, 3, 4), line(2, 0, 0.5)] }); + const { distanceKm } = await import('../js/geo.js'); + const deg = distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }); // km per degree on this planet's equator + assert.ok(Math.abs(s.length[1] - 3 * deg) < 1, String(s.length[1])); + assert.ok(Math.abs(s.length[2] - 0.5 * deg) < 1, String(s.length[2])); +}); diff --git a/viewer/tests/search.test.mjs b/viewer/tests/search.test.mjs new file mode 100644 index 0000000..12b5e83 --- /dev/null +++ b/viewer/tests/search.test.mjs @@ -0,0 +1,55 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCoords, isCellId, suggest } from '../js/search.js'; + +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('coordinate formats', () => { + const cases = [['40N 10W', 40, -10], ['40.5, -10.25', 40.5, -10.25], ['-12 170', -12, 170], ["40°30'N 10°15'W", 40.5, -10.25], + ['10W 40N', 40, -10], ['N40 E10', 40, 10], ['40.5 N, 10.25 E', 40.5, 10.25], ['12°30\'15"S 45°E', -(12 + 30 / 60 + 15 / 3600), 45]]; + for (const [q, lat, lon] of cases) { + const c = parseCoords(q); + assert.ok(c, q); + near(c.lat, lat, 1e-9); + near(c.lon, lon, 1e-9); + } +}); + +test('junk is rejected', () => { + for (const q of ['', 'hello', '40', '95 10', '10 200', '40N 10N', "40°75'N 10W", '40N 10W 5', 'Cell Am Zee', 'NEWS', '4O 10']) { + assert.equal(parseCoords(q), null, q); + } +}); + +test('H3 cell ids', () => { + assert.equal(isCellId('8528308ffffffff'), true); + assert.equal(isCellId(' 8528308FFFFFFFF '), true); + assert.equal(isCellId('0528308ffffffff'), false); + assert.equal(isCellId('8528308fffffff'), false); +}); + +test('suggestions: coordinates, cells, then pins', () => { + const pins = [{ id: 'pin-1', name: 'Zeta ford', lat: 1, lon: 2, lore: null, note: '' }, + { id: 'pin-2', name: 'Alpha peak', lat: 3, lon: 4, lore: 'physical-world', note: '' }]; + assert.deepEqual(suggest('40N 10W', pins).map(s => [s.kind, s.lat, s.lon]), [['coords', 40, -10]]); + assert.deepEqual(suggest('8528308ffffffff', pins).map(s => [s.kind, s.cell]), [['cell', '8528308ffffffff']]); + assert.deepEqual(suggest('a', pins).map(s => s.label), ['Alpha peak', 'Zeta ford']); + assert.deepEqual(suggest(' ', pins), []); +}); + +test('decimal commas in a two-number entry', () => { + const a = parseCoords('40,5 10,25'), b = parseCoords('40,5N 10,25W'), c = parseCoords('40,5'); + assert.deepEqual([a.lat, a.lon], [40.5, 10.25]); + assert.deepEqual([b.lat, b.lon], [40.5, -10.25]); + assert.deepEqual([c.lat, c.lon], [40, 5]); // no space: still "lat,lon" +}); + +test('decimal commas anywhere, and "lat,lon" still wins when it parses', () => { + for (const [q, lat, lon] of [['40 10,5', 40, 10.5], ['40.5 10,25', 40.5, 10.25], ['40N 10,5E', 40, 10.5], + ['40,5 10', 40.5, 10], ['40,5 E', 40, 5]]) { + const c = parseCoords(q); + assert.ok(c, q); + assert.deepEqual([c.lat, c.lon], [lat, lon], q); + } +}); diff --git a/viewer/tests/searchbox.test.mjs b/viewer/tests/searchbox.test.mjs new file mode 100644 index 0000000..bdf79b1 --- /dev/null +++ b/viewer/tests/searchbox.test.mjs @@ -0,0 +1,65 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FakeEl, installFakeDom } from './fakedom.mjs'; + +installFakeDom(); +const { SearchBox } = await import('../js/searchbox.js'); +const tick = () => new Promise(r => setTimeout(r, 0)); + +function box(pins = [], locate = async () => ({ lat: 1, lon: 2 })) { + const input = new FakeEl('input'), list = new FakeEl('div'), went = []; + new SearchBox(input, list, { pins: () => pins, locate, go: r => went.push(r) }); + return { input, list, went }; +} + +test('typing suggests; Enter flies to coordinates with a marker', async () => { + const { input, list, went } = box(); + input.value = '40N 10W'; + input.fire('input'); + assert.equal(list.hidden, false); + assert.equal(list.kids.length, 1); + input.fire('keydown', { key: 'Enter' }); + await tick(); + assert.deepEqual([went[0].lat, went[0].lon, went[0].marker], [40, -10, true]); + assert.equal(list.hidden, true); +}); + +test('arrows move the selection; pins fly without a marker', async () => { + const pins = [{ name: 'Alpha', lat: 3, lon: 4, lore: null, note: '' }, { name: 'Alpine', lat: 5, lon: 6, lore: null, note: '' }]; + const { input, list, went } = box(pins); + input.value = 'alp'; + input.fire('input'); + assert.equal(list.kids.length, 2); + input.fire('keydown', { key: 'ArrowDown' }); + assert.match(list.kids[1].className, /sel/); + input.fire('keydown', { key: 'Enter' }); + await tick(); + assert.deepEqual([went[0].lat, went[0].lon, went[0].marker], [5, 6, false]); +}); + +test('cell ids go through the server; failures are reported', async () => { + const ok = box([], async cell => ({ cell, lat: -20, lon: 100 })); + ok.input.value = '8528308ffffffff'; + ok.input.fire('input'); + ok.input.fire('keydown', { key: 'Enter' }); + await tick(); + assert.deepEqual([ok.went[0].lat, ok.went[0].lon], [-20, 100]); + const bad = box([], async () => { throw new Error('nope'); }); + bad.input.value = '8528308ffffffff'; + bad.input.fire('input'); + bad.input.fire('keydown', { key: 'Enter' }); + await tick(); + assert.equal(bad.went[0].error, 'nope'); +}); + +test('Esc closes the list; junk shows nothing', () => { + const { input, list } = box(); + input.value = '95 10'; + input.fire('input'); + assert.equal(list.hidden, true); + input.value = '40N 10W'; + input.fire('input'); + input.fire('keydown', { key: 'Escape' }); + assert.equal(list.hidden, true); +}); diff --git a/viewer/tests/shading.test.mjs b/viewer/tests/shading.test.mjs new file mode 100644 index 0000000..65f4162 --- /dev/null +++ b/viewer/tests/shading.test.mjs @@ -0,0 +1,57 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fakeRenderer } from './importmap.mjs'; + +const { GlobeView } = await import('../js/globe.js'); +const { FlatView } = await import('../js/flatmap.js'); +const { SHADE_GLSL } = await import('../js/shading.js'); + +const makers = [['globe', r => new GlobeView(r, () => {})], ['equal_earth', r => new FlatView(r, 'equal_earth', () => {})], + ['plate_carree', r => new FlatView(r, 'plate_carree', () => {})]]; + +for (const [name, make] of makers) { + test(`${name}: over-layer blend and sun lighting drive the shared uniforms`, () => { + const v = make(fakeRenderer()), u = v.shade, tex = { isTexture: true }; + v.setOver(tex, 0.4); + assert.equal(u.map2.value, tex); + assert.equal(u.mix2.value, 0.4); + v.setOver(null, 0.4); + assert.equal(u.mix2.value, 0); + v.setSun([0, 1, 0]); + assert.equal(u.sky.value, 1); + assert.deepEqual(u.sunDir.value.toArray(), [0, 1, 0]); + v.setSun(null); + assert.equal(u.sky.value, 0); + assert.ok(v.material.fragmentShader.includes('shadeColor(')); + assert.equal(v.material.uniforms.mix2, u.mix2, 'material and view share uniform objects'); + }); +} + +test('GLSL chunk declares the shared uniforms and the shading function', () => { + for (const n of ['uniform sampler2D map2', 'uniform float mix2', 'uniform vec3 sunDir', 'uniform float sky', 'vec3 shadeColor(']) { + assert.ok(SHADE_GLSL.includes(n), n); + } +}); + +test('haze uniforms exist and default to none', async () => { + const { shadeUniforms, SHADE_GLSL } = await import('../js/shading.js'); + const u = shadeUniforms(); + assert.equal(u.hazeAmt.value, 0); + assert.ok(u.camPos.value && u.hazeCol.value && u.hazeK.value > 0); + assert.match(SHADE_GLSL, /hazeAmt/); +}); + +test('over-layer keeps only the base\'s light and dark: its colour reads true, base colour (river blue) fades', async () => { + const { overMix, SHADE_GLSL } = await import('../js/shading.js'); + const white = [0.94, 0.94, 0.94], river = [0.2, 0.4, 0.8], grass = [0.3, 0.45, 0.25]; + const a = overMix(river, white, 0.5), b = overMix(grass, white, 0.5); + const spread = c => Math.max(...c) - Math.min(...c); + assert.ok(spread(a) < 0.12, `river under a white over-layer stays nearly grey: ${a}`); + assert.ok(Math.abs(a[2] - b[2]) < 0.12, 'river no longer stands out as blue'); + assert.deepEqual(overMix(river, white, 0).map(v => +v.toFixed(6)), river); + const full = overMix(grass, [0.08, 0.47, 0.7], 1); + assert.ok(full[2] > full[0] + 0.3, 'at full opacity the over colour shows, lit by the base'); + assert.match(SHADE_GLSL, /vec3 overColor\(vec3 c, vec3 o, float k\)/); + assert.match(SHADE_GLSL, /overColor\(c, texture2D\(map2, uv\)\.rgb, mix2\)/); +}); diff --git a/viewer/tests/sky.test.mjs b/viewer/tests/sky.test.mjs new file mode 100644 index 0000000..836508b --- /dev/null +++ b/viewer/tests/sky.test.mjs @@ -0,0 +1,38 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { subsolar, sunVec, dayFraction, seasonName, DAY_MOMENTS } from '../js/sky.js'; +import { toLatLon } from '../js/geo.js'; + +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('sub-solar latitude follows the 20° tilt over the 216-day year', () => { + near(subsolar(0, 0).lat, 0, 1e-9); + near(subsolar(54, 0).lat, 20, 1e-9); + near(subsolar(108, 0).lat, 0, 1e-9); + near(subsolar(162, 0).lat, -20, 1e-9); +}); + +test('sub-solar longitude: noon at 0° at moment 23,328, midnight at 0° at moment 0', () => { + near(subsolar(0, DAY_MOMENTS / 2).lon, 0, 1e-9); + near(Math.abs(subsolar(0, 0).lon), 180, 1e-9); + near(subsolar(0, DAY_MOMENTS / 4).lon, 90, 1e-9); + const v = toLatLon(sunVec(54, DAY_MOMENTS / 2)); + near(v.lat, subsolar(54, DAY_MOMENTS / 2).lat, 1e-6); +}); + +test('day length', () => { + near(dayFraction(0, 30), 0.5, 1e-9); + assert.equal(dayFraction(80, 54), 1); + assert.equal(dayFraction(80, 162), 0); + assert.equal(dayFraction(-80, 54), 0); + assert.equal(dayFraction(90, 0), 0.5); + assert.ok(dayFraction(45, 54) > 0.5 && dayFraction(45, 162) < 0.5); +}); + +test('season names near the equinoxes and solstices only', () => { + assert.equal(seasonName(0), 'northern spring equinox'); + assert.equal(seasonName(55), 'northern midsummer'); + assert.equal(seasonName(215), 'northern spring equinox'); + assert.equal(seasonName(30), ''); +}); diff --git a/viewer/tests/skypanel.test.mjs b/viewer/tests/skypanel.test.mjs new file mode 100644 index 0000000..c746bd8 --- /dev/null +++ b/viewer/tests/skypanel.test.mjs @@ -0,0 +1,16 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dayLabel, momentLabel, daylightLabel, SKY_LINES } from '../js/skypanel.js'; + +test('labels', () => { + assert.equal(dayLabel(54), 'day 54 of 216 · northern midsummer'); + assert.equal(dayLabel(30), 'day 30 of 216'); + assert.equal(momentLabel(23328), 'moment 23,328 of 46,656'); + assert.equal(daylightLabel(0, 30), 'daylight 50% (15.6 h)'); +}); + +test('tropics and polar circles', () => { + assert.deepEqual(SKY_LINES.map(l => l[0].lat), [20, -20, 70, -70]); + assert.ok(SKY_LINES.every(l => l.length === 181 && l[0].lon === -180 && l.at(-1).lon === 180)); +}); 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'); +}); diff --git a/viewer/tests/tiles.test.mjs b/viewer/tests/tiles.test.mjs new file mode 100644 index 0000000..da3bf2a --- /dev/null +++ b/viewer/tests/tiles.test.mjs @@ -0,0 +1,126 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tileOf, tileBounds, targetZoom, pxKm, tilesForPoints, parentTile, tileKey, globePatch, flatPatch, MAX_TILE_Z } from '../js/tiles.js'; +import { toLatLon, project } from '../js/geo.js'; + +const near = (a, b, eps) => assert.ok(Math.abs(a - b) <= eps, `${a} vs ${b}`); + +test('tile of a point: wraps east–west, clamps at the poles', () => { + assert.deepEqual(tileOf(90, -180, 3), { z: 3, x: 0, y: 0 }); + assert.deepEqual(tileOf(-90, 180, 3), { z: 3, x: 0, y: 7 }); + assert.deepEqual(tileOf(0.1, 179.99, 3), { z: 3, x: 15, y: 3 }); + const b = tileBounds(tileOf(12.3, 45.6, 9)); + assert.ok(b.west <= 45.6 && 45.6 < b.east && b.south < 12.3 && 12.3 <= b.north); +}); + +test('target zoom: tile pixels just finer than screen pixels', () => { + for (const k of [50, 9.8, 1, 0.1, 0.004]) { + const z = targetZoom(k); + assert.ok(pxKm(z) <= k * (1 + 1e-9), `${k}: z ${z}`); + if (z > 0) assert.ok(pxKm(z - 1) > k); + } + assert.equal(targetZoom(1e-6), MAX_TILE_Z); + assert.equal(targetZoom(1e6), 0); + assert.equal(targetZoom(Infinity), 0); +}); + +test('tiles for points: neighbours, dedupe, antimeridian wrap, pole edge', () => { + const t = tilesForPoints([{ lat: 0.1, lon: 179.99 }], 3, 1).map(tileKey); + assert.equal(t.length, 9); + assert.ok(t.includes('3/0/3') && t.includes('3/14/2')); + assert.equal(tilesForPoints([{ lat: 0.1, lon: 179.99 }, { lat: 0.2, lon: 179.98 }], 3, 0).length, 1); + assert.equal(tilesForPoints([{ lat: 89.9, lon: 0 }], 3, 1).length, 6); +}); + +test('parent tiles', () => { + assert.deepEqual(parentTile({ z: 6, x: 45, y: 13 }), { z: 5, x: 22, y: 6 }); +}); + +test('patches: globe vertices on the sphere with north-west first; flat vertices on the projection', () => { + const t = { z: 7, x: 130, y: 40 }, b = tileBounds(t), g = globePatch(t, 4); + for (let k = 0; k < g.positions.length / 3; k++) { + const v = [0, 1, 2].map(i => g.positions[3 * k + i] + g.center[i]); + near(Math.hypot(...v), 1, 1e-6); + } + const nw = toLatLon([0, 1, 2].map(i => g.positions[i] + g.center[i])); + near(nw.lat, b.north, 1e-4); + near(nw.lon, b.west, 1e-4); + assert.deepEqual([g.uvs[0], g.uvs[1]], [0, 1]); + assert.equal(g.indices.length, 4 * 4 * 6); + const f = flatPatch(t, 'equal_earth', 4), p = project('equal_earth', b.north, b.west); + near(f.positions[0] + f.center[0], p.x, 1e-6); + near(f.positions[1] + f.center[1], p.y, 1e-6); + near(f.ll[0], (b.north * Math.PI) / 180, 1e-6); +}); + +test('3D patch: vertices lifted by height × exaggeration, skirts hang below the border, flat patch unchanged', async () => { + const { globePatch, gridHeight, MESH_SEGS } = await import('../js/tiles.js'); + const { R_KM } = await import('../js/geo.js'); + const t = { z: 10, x: 600, y: 300 }, n = MESH_SEGS + 1, R_M = R_KM * 1000; + const h = Float32Array.from({ length: n * n }, (_, k) => 100 + (k % n)); // rises eastward + const flat = globePatch(t, 16), p = globePatch(t, MESH_SEGS, h, 3); + assert.equal(flat.positions.length, 17 * 17 * 3); + assert.equal(p.positions.length, (n * n + 4 * MESH_SEGS) * 3); + assert.equal(p.uvs.length, (n * n + 4 * MESH_SEGS) * 2); + const r = k => Math.hypot(...[0, 1, 2].map(i => p.positions[3 * k + i] + p.center[i])); + assert.ok(Math.abs(r(0) - (1 + (3 * 100) / R_M)) < 1e-9); + assert.ok(Math.abs(r(n - 1) - (1 + (3 * (100 + n - 1)) / R_M)) < 1e-9); + assert.ok(r(n * n) < r(0) - 1e-9, 'the first skirt vertex hangs below its border vertex'); + const spacingM = (180 / 1024 / MESH_SEGS) * Math.PI / 180 * R_M; + assert.ok((r(0) - r(n * n)) * R_M >= 2 * spacingM * 3 - 0.05, 'skirts reach 2 vertex spacings × exaggeration (two zoom levels of mismatch)'); + assert.ok(p.indices.every(i => i < n * n + 4 * MESH_SEGS)); + assert.equal(p.indices.length, MESH_SEGS * MESH_SEGS * 6 + 4 * MESH_SEGS * 6); + const b = { west: -180 + 600 * 180 / 1024, north: 90 - 300 * 180 / 1024 }, s = 180 / 1024; + assert.equal(gridHeight(t, h, b.north, b.west), 100); + assert.ok(Math.abs(gridHeight(t, h, b.north - s / 2, b.west + s / 2) - (100 + MESH_SEGS / 2)) < 1e-9, 'centre: bilinear'); + assert.equal(gridHeight(t, h, b.north - s, b.west + s), 100 + n - 1); +}); + +test('3D patch: skirt vertices are flagged (shaded as the ground above them, not as cliffs)', async () => { + const { globePatch, MESH_SEGS } = await import('../js/tiles.js'); + const t = { z: 10, x: 600, y: 300 }, n = MESH_SEGS + 1; + const p = globePatch(t, MESH_SEGS, new Float32Array(n * n).fill(50), 1); + assert.equal(p.skirt.length, n * n + 4 * MESH_SEGS); + assert.ok(p.skirt.subarray(0, n * n).every(v => v === 0), 'the grid: no skirt'); + assert.ok(p.skirt.subarray(n * n).every(v => v === 1), 'the skirt'); + assert.equal(globePatch(t, 16).skirt, undefined, 'a flat patch has none'); +}); + +test('lodTiles: uniform zoom = tilesForPoints; mixed zooms never overlap and cover every point', async () => { + const { lodTiles, tilesForPoints, tileOf, tileKey, parentTile } = await import('../js/tiles.js'); + const pts = Array.from({ length: 30 }, (_, k) => ({ lat: 40 + (k % 6) * 0.02, lon: 10 + Math.floor(k / 6) * 0.03 })); + const u = lodTiles(pts, pts.map(() => 11)); + assert.deepEqual(new Set(u.map(tileKey)), new Set(tilesForPoints(pts, 11, 1).map(tileKey))); + assert.equal(tileKey(u[0]), tileKey(tileOf(40, 10, 11)), 'first point first'); + const zs = pts.map((_, k) => (k < 6 ? 14 : k < 18 ? 12 : 9)); // near → far + const m = lodTiles(pts, zs), keys = new Set(m.map(tileKey)); + for (const t of m) for (let a = parentTile(t); a.z >= 5; a = parentTile(a)) assert.ok(!keys.has(tileKey(a)), `overlap ${tileKey(t)}`); + pts.forEach((p, k) => { + const cover = m.filter(t => { const o = tileOf(p.lat, p.lon, t.z); return o.x === t.x && o.y === t.y; }); + assert.equal(cover.length, 1, `point ${k}`); + assert.ok(cover[0].z >= zs[k], `point ${k} never coarser than it asked`); + }); + assert.ok(m.some(t => t.z === 14) && m.some(t => t.z < 12)); + assert.deepEqual(lodTiles(pts, pts.map(() => 3)), [], 'below the tile zooms: nothing'); +}); + +test('3D patches use the 129 × 129 server heights (128 segments)', async () => { + const { MESH_SEGS } = await import('../js/tiles.js'); + assert.equal(MESH_SEGS, 128); +}); + +test('fine grain: its texel coordinates run on across tile edges (wrapping every DETAIL_TILES tiles and the antimeridian)', async () => { + const { detailOrigin, DETAIL_TILES, TILE } = await import('../js/tiles.js'); + const P = DETAIL_TILES * TILE, mod = v => ((v % P) + P) % P; + for (const z of [5, 12, 16]) { + const nx = 2 ** (z + 1), ny = 2 ** z; + for (const [x, y] of [[0, 0], [7, 3], [DETAIL_TILES - 1, DETAIL_TILES - 1], [nx - 1, ny - 2], [54367, 37971 % ny]]) { + const o = detailOrigin({ z, x, y }); + const e = detailOrigin({ z, x: (x + 1) % nx, y }), s = detailOrigin({ z, x, y: y + 1 }); + assert.equal(mod(o[0] + TILE), mod(e[0]), `east of ${z}/${x}/${y}`); + assert.equal(mod(o[1] + TILE), mod(s[1]), `south of ${z}/${x}/${y}`); + assert.ok(o.every(v => v >= 0 && v < P), 'small numbers: float32 keeps sub-texel precision'); + } + } +}); diff --git a/viewer/tests/units.test.mjs b/viewer/tests/units.test.mjs new file mode 100644 index 0000000..d4af9d8 --- /dev/null +++ b/viewer/tests/units.test.mjs @@ -0,0 +1,38 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as U from '../js/units.js'; + +U.setUnits({ span_m: 0.331013, league_km: 15.4437 }); + +test('conversions', () => { + assert.ok(Math.abs(U.kmToLeagues(15.4437) - 1) < 1e-12); + assert.ok(Math.abs(U.mToSpans(0.331013) - 1) < 1e-12); + assert.ok(Math.abs(U.km2ToSqLeagues(15.4437 ** 2) - 1) < 1e-12); + assert.equal(U.walkDays(90), 3); + assert.equal(U.fmtDistance(1000), '1,000 km · 64.8 leagues'); + assert.equal(U.fmtDistance(12.34), '12.3 km · 0.80 leagues'); + assert.equal(U.fmtWalk(45), '1.5 days on foot'); + assert.equal(U.fmtLatLon(12.5, -3.25), '12.500° N, 3.250° W'); + assert.equal(U.fmtLatLon(-0.5, 179), '0.500° S, 179.000° E'); + assert.equal(U.fmtDistance(0.5), '500 m · 1,511 spans'); + assert.equal(U.fmtElev(2344), '2,344 m · 7,081 spans'); + assert.equal(U.fmtElev(-12.4), '-12 m · -37 spans'); +}); + +test('niceKm picks 1/2/5 steps', () => { + assert.deepEqual([123, 260, 0.7, 5, 999].map(U.niceKm), [100, 200, 0.5, 5, 500]); +}); + +test('setUnits overrides', () => { + U.setUnits({ league_km: 10 }); + assert.equal(U.kmToLeagues(25), 2.5); + U.setUnits({ league_km: 15.4437 }); +}); + +test('SI only without in-world units', () => { + U.setUnits({ span_m: null, league_km: null }); + assert.equal(U.fmtDistance(12.34), '12.3 km'); + assert.equal(U.fmtElev(2344), '2,344 m'); + U.setUnits({ span_m: 0.331013, league_km: 15.4437 }); +}); diff --git a/viewer/tests/urlstate.test.mjs b/viewer/tests/urlstate.test.mjs new file mode 100644 index 0000000..f32656e --- /dev/null +++ b/viewer/tests/urlstate.test.mjs @@ -0,0 +1,77 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parse, serialize, DEFAULT, carryHash } from '../js/urlstate.js'; + +test('round trip', () => { + const s = { ...DEFAULT, layer: 'biomes', view: 'equal_earth', lat: 40.5, lon: -10.25, alt: 0.5, zoom: 3, overlays: ['coast', 'pins'] }; + assert.deepEqual(parse(serialize(s)), s); + const t = { ...s, over: 'biomes', op: 0.35, sky: true, day: 100, mo: 12345, alt: 0.0004, zoom: 20000 }; + assert.deepEqual(parse(serialize(t)), t); +}); + +test('new keys clamp and default', () => { + const s = parse('#op=7&day=300&mo=-5&sky=maybe&alt=0.00001&zoom=1e9'); + assert.equal(s.op, 1); + assert.equal(s.day, 215); + assert.equal(s.mo, 0); + assert.equal(s.sky, false); + assert.equal(s.alt, 0.00001); // above the 20 m floor now + assert.equal(s.zoom, 20000); + assert.equal(s.over, ''); +}); + +test('garbage and out-of-range values fall back or clamp', () => { + const s = parse('#view=nope&lat=abc&lon=200&alt=99&zoom=0'); + assert.equal(s.view, DEFAULT.view); + assert.equal(s.lat, DEFAULT.lat); + assert.equal(s.lon, -160); + assert.equal(s.alt, 5); + assert.equal(s.zoom, 1); + assert.deepEqual(parse(''), DEFAULT); +}); + +test('clamps come from the shared view limits', async () => { + const { MIN_ALT, MAX_ALT, MAX_ZOOM } = await import('../js/limits.js'); + const s = parse('#alt=1e-9&zoom=1e12'); + assert.equal(s.alt, MIN_ALT); + assert.equal(s.zoom, MAX_ZOOM); + assert.equal(parse('#alt=1e9').alt, MAX_ALT); +}); + +test('tilt and 3D: round trip, clamps, only written when they differ from the default', () => { + const s = { ...DEFAULT, alt: 0.001, hd: 30.5, pt: 45, terrain: 2.5 }; + assert.deepEqual(parse(serialize(s)), s); + assert.deepEqual(parse(serialize({ ...DEFAULT, terrain: 0 })), { ...DEFAULT, terrain: 0 }); + assert.ok(!/hd=|pt=|3d=/.test(serialize(DEFAULT))); + assert.ok(!/hd=|pt=/.test(serialize({ ...DEFAULT, view: 'equal_earth', hd: 30, pt: 40 })), 'flat views have no tilt'); + const c = parse('#hd=-90&pt=95&3d=9'); + assert.deepEqual([c.hd, c.pt, c.terrain], [270, 80, 5]); + assert.equal(parse('#3d=0.4').terrain, 1); + assert.equal(parse('#3d=-2').terrain, 0); + assert.equal(parse('#3d=abc').terrain, 1); +}); + +test('era and water round-trip; defaults stay out of the URL', () => { + const s = { ...DEFAULT, era: 'age-of-legends', water: false }; + assert.deepEqual(parse(serialize(s)), s); + assert.doesNotMatch(serialize(DEFAULT), /era=|water=/); + assert.equal(parse('#water=1').water, true); + assert.equal(parse('#era=').era, ''); +}); + +test('the sea floor replaces relief only on the 3D globe with the water off', async () => { + const { drawnLayer } = await import('../js/urlstate.js'); + const s = { ...DEFAULT, water: false }; + assert.equal(drawnLayer(s, 'globe'), 'seafloor'); + assert.equal(drawnLayer({ ...s, terrain: 0 }, 'globe'), 'relief', '3D off: the water toggle is hidden'); + assert.equal(drawnLayer(s, 'equal_earth'), 'relief'); + assert.equal(drawnLayer({ ...s, layer: 'biomes' }, 'globe'), 'biomes'); + assert.equal(drawnLayer(DEFAULT, 'globe'), 'relief'); +}); + +test('carryHash keeps the view, drops era and time', () => { + assert.equal(carryHash('#layer=ground&view=globe&lat=10&era=high-fantasy&sky=1&day=50&mo=9'), '#layer=ground&view=globe&lat=10&sky=1'); + assert.equal(carryHash('#era=x'), ''); + assert.equal(carryHash(''), ''); +}); diff --git a/viewer/tests/vents.test.mjs b/viewer/tests/vents.test.mjs new file mode 100644 index 0000000..78d3386 --- /dev/null +++ b/viewer/tests/vents.test.mjs @@ -0,0 +1,27 @@ +import './_planet.mjs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { clusterVents, ventText, zoomBucket } from '../js/vents.js'; + +const V = (lat, lon, type = 0) => ({ lat, lon, type, temp_c: 350, flow: 2, mineral: type }); + +test('far apart: one marker each; close together: one cluster with a count', () => { + const vents = [V(0, 0), V(0.01, 0.01), V(10, 10)]; + const fine = clusterVents(vents, 0.01); // 40 px ≈ 0.4 km: nothing merges + assert.equal(fine.length, 3); + assert.ok(fine.every(c => c.n === 1 && c.v)); + const coarse = clusterVents(vents, 10); // 40 px ≈ 400 km + assert.equal(coarse.length, 2); + const big = coarse.find(c => c.n === 2); + assert.ok(big && Math.abs(big.lat - 0.005) < 1e-9); +}); + +test('hover text names type, temperature and mineral', () => { + const t = ventText(V(0, 0, 0), ['black smoker', 'white smoker'], ['copper-iron sulfides', 'zinc sulfides and barite']); + assert.equal(t, 'black smoker · 350 °C · copper-iron sulfides · strong flow'); +}); + +test('zoom buckets change every ×1.5', () => { + assert.equal(zoomBucket(1), zoomBucket(1.2)); + assert.notEqual(zoomBucket(1), zoomBucket(1.6)); +}); |
