Merge branch 'main' into contrib/gods-023-firms-coordinate-evidence-20261011-reviewed

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sameh Khamis committed 2026-10-14 19:21:08 -07:00
commit 493b154a6b
8 files changed
+180 -24

No files matched your search

@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCentre,
boxFromRectangle,
dequantizeBox,
validateBox,
} from './model.js';
import { searchHls } from './catalog.js';
test('missing imagery edges do not turn into invented zero coordinates', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
const box = { west: value, south: 0, east: 1, north: 1 };
assert.equal(validateBox(box).ok, false);
assert.equal(validateBox(box).reason, 'invalid');
assert.equal(boxCentre(box), null);
}
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
assert.equal(
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
true,
);
});
test('every edge and every box entry point refuses a missing edge', async () => {
const blanks = [null, undefined, '', ' ', false, [], {}];
for (const edge of ['west', 'south', 'east', 'north']) {
for (const value of blanks) {
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
// Share-link integers and Cesium radians are converted per edge.
assert.equal(dequantizeBox(box), null, `share ${edge}`);
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
}
}
let fetched = false;
await assert.rejects(
searchHls({
box: { west: ' ', south: 1, east: 2, north: 2 },
fetchImpl: async () => {
fetched = true;
},
}),
TypeError,
);
assert.equal(fetched, false);
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
});
+19 -6
View File
@@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
/** A box edge as a number, or null when it is missing, blank or not numeric. */
function edgeNumber(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
@@ -82,8 +90,8 @@ function normalizeBox(box) {
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
].map(edgeNumber);
if ([west, south, east, north].some((edge) => edge == null)) return null;
return {
west,
south: Math.min(south, north),
@@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
const edge = (value) => {
const number = edgeNumber(value);
return number == null ? null : fn(number);
};
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
west: edge(box.west),
south: edge(box.south),
east: edge(box.east),
north: edge(box.north),
});
}
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tilesForBbox } from './tileMath.js';
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
const bbox = [-10, 60, -20, 85];
const result = tilesForBbox(bbox, 3);
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(keys.length, 24);
assert.equal(new Set(keys).size, 24);
assert.equal(result.total, 24);
assert.equal(result.truncated, false);
const overview = tilesForBbox(bbox, 3, { limit: 16 });
assert.equal(overview.total, 24);
assert.equal(overview.tiles.length, 16);
assert.equal(
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
16,
);
assert.equal(overview.truncated, true);
});
test('a limit that fits every distinct tile is not spent on repeats', () => {
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
// 24-slot overview held repeats and still reported itself truncated.
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(new Set(keys).size, 24);
assert.equal(result.truncated, false);
});
+10 -2
View File
@@ -146,8 +146,16 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
// The date-line halves share a column when the gap between east and west is
// narrower than a tile; count that column once so a capped overview does not
// spend its slots on repeats. Each tile's distance is computed once; ties keep
// their row order.
const unique = [
...new Map(
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
).values(),
];
const ranked = unique
.map((tile) => ({ tile, d: distance2(tile) }))
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
+10 -12
View File
@@ -272,28 +272,26 @@ export function createTerrainHeights({
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
// What this call resolved or found warm, so the assembly below can report a
// point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES.
// Without it a batch big enough to evict its own entries would report
// points as unresolved that it had in fact resolved.
const resolvedThisCall = new Map();
for (const item of work) {
const entry = cache.get(item.key);
const entry = cacheTouch(item.key);
const fallbackCooling =
entry?.source === 'geoid-fallback' &&
Number.isFinite(entry.retryAt) &&
now < entry.retryAt;
if (
(entry && entry.source === 'reearth') ||
fallbackCooling ||
seenKeys.has(item.key)
)
if ((entry && entry.source === 'reearth') || fallbackCooling) {
resolvedThisCall.set(item.key, entry);
continue;
}
if (seenKeys.has(item.key)) continue;
seenKeys.add(item.key);
uncached.push(item);
}
// What this call resolved, so the assembly below can report a point even
// if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a
// batch big enough to evict its own earlier chunks would report points as
// unresolved that it had in fact just resolved.
const resolvedThisCall = new Map();
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
+41
View File
@@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved'
assert.equal(warm.length, 4);
});
test('warm batch consumers retain their terrain cells ahead of unused cells', async () => {
let calls = 0;
const terrain = createTerrainHeights({
maxCacheEntries: 2,
source: {
async getHeights(points) {
calls++;
return points.map((p) => ({ ellipsoid: 100 + p.lon }));
},
},
});
const p = (lon) => ({ lat: 0, lon });
await terrain.resolveEllipsoidalGround([p(1)]);
await terrain.resolveEllipsoidalGround([p(2)]);
await terrain.resolveEllipsoidalGround([p(1)]);
assert.equal(calls, 2);
await terrain.resolveEllipsoidalGround([p(3)]);
assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101);
assert.equal(terrain.cachedEllipsoidalGround(0, 2), null);
assert.equal(calls, 3);
});
test('a batch still reports its warm points after its own fetches evict them', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 2,
});
await terrain.resolveEllipsoidalGround([pointAt(0)]);
const results = await terrain.resolveEllipsoidalGround([
pointAt(0),
pointAt(1),
pointAt(2),
]);
results.forEach((result, i) => {
assert.equal(result.source, 'reearth', `point ${i} reported resolved`);
assert.equal(result.ellipsoid, heightAt(lonAt(i)));
});
});
test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => {
for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) {
const terrain = createTerrainHeights({