Files
gods-eye-view/src/services/terrainHeights.test.mjs
T
Sameh Khamis b4aa61d6ef fix(terrain): keep a batch's warm points when its own fetches evict them
A warm entry could be evicted by the same call's later fetches and then
reported unresolved. The call now remembers warm hits as it does fetched ones.
Moves the batch LRU test beside the existing cache tests and folds the docs
note into the section's eviction paragraph.
2026-10-14 20:11:50 -04:00

172 lines
5.5 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
createTerrainHeights,
DEFAULT_MAX_CACHE_ENTRIES,
} from './terrainHeights.js';
/**
* A terrain source that always resolves, so these tests exercise the cache and
* never the geoid-fallback path. Heights are derived from the coordinate so an
* assertion can name the value it expects.
*/
function stubSource() {
const batches = [];
return {
batches,
async getHeights(chunk) {
batches.push(chunk.map((item) => item.key));
return chunk.map((item) => ({ ellipsoid: heightAt(item.lon) }));
},
};
}
/** Distinct 5-decimal coordinates along the equator: 0.001, 0.002, … */
const lonAt = (i) => i / 1000;
const heightAt = (lon) => 1000 + Math.round(lon * 1000);
const pointAt = (i) => ({ lat: 0, lon: lonAt(i) });
test('the default cache bound is exported so callers can reason about it', () => {
assert.equal(DEFAULT_MAX_CACHE_ENTRIES, 20_000);
});
test('the in-memory cache stops growing once it reaches its entry bound', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 4,
});
// Six distinct coordinates through a cache that holds four.
for (let i = 0; i < 6; i += 1) {
await terrain.resolveEllipsoidalGround([pointAt(i)]);
}
// The two coldest are gone. Before the bound existed the cache kept every
// coordinate a session ever resolved, so both of these still answered.
assert.equal(terrain.cachedEllipsoidalGround(0, lonAt(0)), null);
assert.equal(terrain.cachedEllipsoidalGround(0, lonAt(1)), null);
// The four most recent are untouched — a bound, not a flush.
for (let i = 2; i < 6; i += 1) {
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(i)),
heightAt(lonAt(i)),
`coordinate ${i} stayed warm`,
);
}
});
test('a coordinate a consumer keeps reading outlives an equally old one it ignores', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 4,
});
for (let i = 0; i < 4; i += 1) {
await terrain.resolveEllipsoidalGround([pointAt(i)]);
}
// Read coordinate 0 and ignore coordinate 1. They are otherwise the same age,
// so insertion order alone could not tell them apart.
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(0)),
heightAt(lonAt(0)),
);
// Two fresh coordinates push the cache two entries past its bound.
for (let i = 4; i < 6; i += 1) {
await terrain.resolveEllipsoidalGround([pointAt(i)]);
}
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(0)),
heightAt(lonAt(0)),
'the cell being read survived eviction',
);
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(1)),
null,
'the same-age cell nobody read was evicted instead',
);
});
test('a batch larger than the cache bound still reports every point it resolved', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 4,
});
const batch = Array.from({ length: 10 }, (_, i) => pointAt(i));
const results = await terrain.resolveEllipsoidalGround(batch);
// Eviction inside the call must not turn a point this batch resolved into an
// unresolved one: the results are assembled from what the call produced, not
// from whatever survived in the cache by the time it finished.
assert.equal(results.length, batch.length);
results.forEach((result, i) => {
assert.equal(result.source, 'reearth', `point ${i} reported resolved`);
assert.equal(result.ellipsoid, heightAt(lonAt(i)));
});
// The cache still honours the bound afterwards.
const warm = batch.filter(
(point) => terrain.cachedEllipsoidalGround(point.lat, point.lon) !== null,
);
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({
source: stubSource(),
maxCacheEntries,
});
await terrain.resolveEllipsoidalGround([pointAt(1)]);
assert.equal(
terrain.cachedEllipsoidalGround(0, lonAt(1)),
heightAt(lonAt(1)),
`maxCacheEntries=${String(maxCacheEntries)} kept a working cache`,
);
}
});