Files
gods-eye-view/src/data/cctvVegvesen.test.mjs
T
Said TurkseverandClaude Opus 5.5 c808bfe609 fix(cctv): harden the Kartverket height lookup for Vegvesen cameras
- Default backoff is 30 min, longer than the 15-minute catalog refresh,
  so a failing Kartverket costs one attempt per backoff, not per refresh.
- A point a 200 answer leaves out is cached as no height (6 h TTL)
  instead of being asked again, and waited on, every refresh.
- Only 408, 429 and 5xx stop the lookup; any other 4xx marks that
  request's points as no height and the lookup carries on.
- Each request has its own 10 s timeout inside the shared 30 s deadline;
  a lookup cut short only by the deadline resumes without a backoff.
- The lane waits only on a cold cache (>10% of cameras missing); a few
  new cameras or expired misses are looked up in the background.
- applyVegvesenGroundHeights skips cameras without finite coordinates,
  so it really never throws.
- clearKartverketHeightCache bumps a generation, so a lookup started
  before a clear cannot write to the cache, backoff or pending slot.
- Credit and DATA_SOURCES note the modification (water set to 0 m,
  rounded to 0.1 m) as CC BY 4.0 asks; docs match the stop rules.
- Tests for the deadline being shared, per-request timeouts, 503,
  oversized and non-JSON bodies, 4xx, left-out points, Retry-After end
  to end, overlapping refreshes, the warm-cache wait, bad coordinates,
  the cache-clear generation and the loader's wait budget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:03:50 +01:00

823 lines
27 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
applyVegvesenGroundHeights,
clearKartverketHeightCache,
fetchKartverketHeights,
kartverketBackoffMs,
loadVegvesenSourcesFromOpenData,
vegvesenCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_VEGVESEN_CCTV_URL,
KARTVERKET_HEIGHT_URL,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_CONCURRENCY,
KARTVERKET_FAILURE_BACKOFF_MS,
KARTVERKET_MAX_POINTS,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_NULL_TTL_MS,
KARTVERKET_USER_AGENT,
CCTV_SOURCE_CACHE_MS,
VEGVESEN_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates },
properties: {
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
cameraId: '3000063_1',
roadNumber: 'F614',
'status.stillImageAvailability': 'videoOrImagesAvailable',
orientationDescription: 'Svelgen',
description: 'Langesi',
...props,
},
});
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
const source = vegvesenCameraToSource(feature());
assert.equal(source.id, 'no-vegvesen-3000063_1');
assert.equal(source.name, 'F614 Langesi → Svelgen');
assert.equal(source.city, 'Norway');
assert.equal(source.cityId, 'norway');
assert.equal(source.provider, 'Statens vegvesen');
assert.equal(source.lat, 61.832706);
assert.equal(source.lon, 5.459189);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.sourceKind, 'vegvesen-datex');
assert.equal(source.code, 'LANGESI');
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('cameras that publish HLS get live video with the still as fallback', (t) => {
const video = {
videoServiceLevel: 1,
videoEncodingStandard: 'hls',
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
};
const source = vegvesenCameraToSource(feature(video));
assert.equal(source.feedType, 'hls');
assert.equal(source.url, video.videoUrl);
assert.equal(
source.snapshotUrl,
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
);
// A manifest anywhere but the camera's own path stays a still.
const offPath = vegvesenCameraToSource(
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
);
assert.equal(offPath.feedType, 'image');
assert.equal(offPath.url, offPath.snapshotUrl);
// The kill switch keeps every camera on stills.
const saved = process.env.CCTV_VEGVESEN_VIDEO;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
else process.env.CCTV_VEGVESEN_VIDEO = saved;
});
process.env.CCTV_VEGVESEN_VIDEO = '0';
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
});
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
assert.equal(
vegvesenCameraToSource(
feature({
'status.stillImageAvailability':
'videoOrImagesUnavailableDueToCameraFault',
}),
),
null,
);
assert.equal(
vegvesenCameraToSource(
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
),
null,
);
// The frame URL must be exactly the camera's own image path.
assert.equal(
vegvesenCameraToSource(
feature({
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
}),
),
null,
);
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
// Copenhagen: a plausible coordinate, but outside the Norway box.
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
assert.equal(vegvesenCameraToSource(null), null);
});
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
clearKartverketHeightCache();
t.after(clearKartverketHeightCache);
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const requested = [];
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json({
type: 'FeatureCollection',
features: [
feature(),
feature(),
feature(
{
cameraId: '0629001_1',
stillImageUrl:
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
description: 'Oslo S',
},
[10.752, 59.911],
),
],
});
});
const cameras = await loadVegvesenSourcesFromOpenData();
// The catalog first; the rest is the Kartverket height lookup.
assert.equal(requested[0], DEFAULT_VEGVESEN_CCTV_URL);
// Two unique positions: one Kartverket request.
assert.equal(requested.length, 2);
assert.ok(requested[1].startsWith(KARTVERKET_HEIGHT_URL));
// Nearest-to-anchor first: Oslo leads.
assert.deepEqual(
cameras.map((c) => c.id),
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
);
fetchMock.mock.mockImplementation(
async () => new Response('nope', { status: 503 }),
);
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
fetchMock.mock.mockImplementation(async () => {
throw new Error('offline');
});
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
});
const runCatalog = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
return Response.json({
type: 'FeatureCollection',
features: [feature()],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
const withEnv = async (patch, fn) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_VEGVESEN_ENABLED;
Object.assign(process.env, patch);
await fn();
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('the Vegvesen lane is wired into the catalog', async (t) => {
await withEnv({}, async () => {
const { requested, sources } = await runCatalog(t);
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
assert.deepEqual(
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
['no-vegvesen-3000063_1'],
);
});
});
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
const { requested, sources } = await runCatalog(t);
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
assert.deepEqual(
sources.filter((s) => s.cityId === 'norway'),
[],
);
});
});
/** Answer a Kartverket request the way the live API does: echo every point,
* with the height `heightFor(x, y)` returns (null = outside coverage). */
const kartverketResponse = (url, heightFor) => {
const points = JSON.parse(new URL(url).searchParams.get('punkter'));
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: heightFor(x, y) })),
});
};
/** Cameras at distinct Norwegian positions. */
const cameraAt = (i) => ({
id: `no-vegvesen-${i}`,
lat: 60 + i * 0.001,
lon: 7 + i * 0.001,
groundElevationM: 150,
});
const withHeightsEnv = (t) => {
const saved = process.env.CCTV_VEGVESEN_HEIGHTS;
delete process.env.CCTV_VEGVESEN_HEIGHTS;
clearKartverketHeightCache();
t.after(() => {
clearKartverketHeightCache();
if (saved === undefined) delete process.env.CCTV_VEGVESEN_HEIGHTS;
else process.env.CCTV_VEGVESEN_HEIGHTS = saved;
});
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
};
const sentPoints = (url) =>
JSON.parse(new URL(url).searchParams.get('punkter'));
test('Kartverket heights come in batches of 20, matched by echoed coordinates', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push(String(url));
assert.ok(String(url).startsWith(KARTVERKET_HEIGHT_URL));
assert.equal(init.headers['User-Agent'], KARTVERKET_USER_AGENT);
// Redirects are refused, so a request can never leave the fixed host.
assert.equal(init.redirect, 'error');
// Reply in reverse order: matching must not depend on list position.
const response = kartverketResponse(url, (x) =>
x > 7.04 ? null : Math.round((x - 7) * 10000),
);
const body = await response.json();
body.punkter.reverse();
return Response.json(body);
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 50 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
// One pass, no retries: 20 + 20 + 10.
assert.deepEqual(
requested.map((url) => sentPoints(url).length),
[KARTVERKET_MAX_POINTS, 20, 10],
);
for (const url of requested) {
assert.equal(new URL(url).searchParams.get('koordsys'), '4258');
}
assert.equal(heights.size, 50);
assert.equal(heights.get('7.010000,60.010000'), 100);
// Past x = 7.04 the API has no height: recorded as null, never as 0.
assert.equal(heights.get('7.049000,60.049000'), null);
});
test('only the points a request asked about are recorded', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
...points.map(([x, y]) => ({ x, y, z: 10 })),
{ x: 7.5, y: 60.5, z: 999 }, // never asked
],
});
});
const { heights } = await fetchKartverketHeights([cameraAt(0)]);
assert.deepEqual([...heights.entries()], [['7.000000,60.000000', 10]]);
});
test('either water marker counts as the surface', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
const [a, b, c] = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: [
{
x: a[0],
y: a[1],
z: -547.1,
datakilde: 'dybdekurver',
terreng: null,
},
{ x: b[0], y: b[1], z: -9.2, datakilde: 'dtm1', terreng: 'Havflate' },
{
x: c[0],
y: c[1],
z: -0.2,
datakilde: 'dtm1',
terreng: 'ÅpentOmråde',
},
],
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
]);
assert.equal(heights.get('7.000000,60.000000'), 0);
assert.equal(heights.get('7.001000,60.001000'), 0);
// A quay just below the datum on land data stays as measured.
assert.equal(heights.get('7.002000,60.002000'), -0.2);
});
test('heights outside -20..2500 m and points outside Norway are not used', async (t) => {
withHeightsEnv(t);
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
const zs = [-20, -20.5, 2500, 2500.5];
const points = sentPoints(url);
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y], i) => ({ x, y, z: zs[i] })),
});
});
const { heights } = await fetchKartverketHeights([
cameraAt(0),
cameraAt(1),
cameraAt(2),
cameraAt(3),
{ lat: 55.68, lon: 12.57 }, // Copenhagen: never sent
]);
assert.equal(requested.length, 1);
assert.equal(sentPoints(requested[0]).length, 4);
assert.deepEqual(
[0, 1, 2, 3].map((i) =>
heights.get(
`${(7 + i * 0.001).toFixed(6)},${(60 + i * 0.001).toFixed(6)}`,
),
),
[-20, null, 2500, null],
);
});
test('no more than eight requests are in flight', async (t) => {
withHeightsEnv(t);
let inFlight = 0;
let peak = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return kartverketResponse(url, () => 1);
});
const { heights } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(heights.size, 400);
assert.equal(peak, KARTVERKET_CONCURRENCY);
});
test('a transport failure stops the lookup: no retries, no new requests', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return new Response('busy', {
status: 429,
headers: { 'Retry-After': '120' },
});
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
// The first wave is already in flight when the first failure lands; nothing
// after it starts, and no point is resent.
assert.equal(calls, KARTVERKET_CONCURRENCY);
assert.equal(heights.size, 0);
assert.deepEqual(failure, {
status: 429,
retryAfter: '120',
deadline: false,
});
});
/** A fetch that never answers, and rejects only when its signal aborts. */
const hangingFetch = (url, init) => {
assert.ok(init.signal instanceof AbortSignal);
return new Promise((_, reject) => {
init.signal.addEventListener('abort', () => reject(init.signal.reason));
});
};
test('the lookup deadline bounds a hanging service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const started = Date.now();
const { heights, failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 30,
});
const elapsed = Date.now() - started;
assert.ok(elapsed >= 25 && elapsed < 2000, `elapsed ${elapsed} ms`);
assert.equal(heights.size, 0);
assert.deepEqual(failure, { status: null, retryAfter: null, deadline: true });
});
test('one hanging request times out on its own, as a failing service', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', hangingFetch);
const { failure } = await fetchKartverketHeights([cameraAt(0)], {
timeoutMs: 60_000,
requestTimeoutMs: 20,
});
assert.deepEqual(failure, {
status: null,
retryAfter: null,
deadline: false,
});
});
test('the deadline is shared: later requests do not get a fresh one', async (t) => {
withHeightsEnv(t);
let calls = 0;
t.mock.method(globalThis, 'fetch', (url, init) => {
calls += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(kartverketResponse(url, () => 1)),
40,
);
init.signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(init.signal.reason);
});
});
});
// Five waves of 40 ms against a 100 ms deadline for the whole lookup.
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 5 * KARTVERKET_CONCURRENCY * 20 }, (_, i) =>
cameraAt(i),
),
{ timeoutMs: 100 },
);
assert.equal(failure?.deadline, true);
assert.ok(heights.size < 5 * KARTVERKET_CONCURRENCY * 20);
assert.ok(calls < 5 * KARTVERKET_CONCURRENCY, `calls ${calls}`);
});
test('a 503, an unreadable or an oversized body stops the lookup', async (t) => {
withHeightsEnv(t);
const answers = {
503: () => new Response('down', { status: 503 }),
'not json': () =>
new Response('<html>maintenance</html>', {
headers: { 'Content-Type': 'text/html' },
}),
oversized: () =>
Response.json({
punkter: [],
pad: 'x'.repeat(KARTVERKET_MAX_RESPONSE_BYTES + 1),
}),
};
for (const [label, answer] of Object.entries(answers)) {
clearKartverketHeightCache();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return answer();
});
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 400 }, (_, i) => cameraAt(i)),
);
assert.equal(calls, KARTVERKET_CONCURRENCY, label);
assert.equal(heights.size, 0, label);
assert.equal(failure?.deadline, false, label);
assert.equal(failure?.status, label === '503' ? 503 : null, label);
}
});
test('a 4xx rejects that request only: its points get no height, the rest go on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
sentPoints(url)[0][0] === 7
? new Response('bad point', { status: 400 })
: kartverketResponse(url, () => 42),
);
const { heights, failure } = await fetchKartverketHeights(
Array.from({ length: 40 }, (_, i) => cameraAt(i)),
);
assert.equal(failure, null);
assert.equal(heights.size, 40);
assert.equal(heights.get('7.000000,60.000000'), null);
assert.equal(heights.get('7.020000,60.020000'), 42);
});
test('a point a 200 answer leaves out is cached as no height', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
const points = sentPoints(url).slice(1); // drop the first point
return Response.json({
koordsys: 4258,
punkter: points.map(([x, y]) => ({ x, y, z: 7 })),
});
});
const cameras = [cameraAt(0), cameraAt(1)];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[0].groundElevationM, 150);
// The next refresh asks nothing: the left-out point is a cached miss.
assert.equal(await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1)]), 1);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('Retry-After sets the backoff, bounded to [1 min, 1 h]', () => {
const now = Date.parse('2026-10-06T12:00:00Z');
assert.equal(kartverketBackoffMs('120', now), 120_000);
assert.equal(kartverketBackoffMs('5', now), 60_000);
assert.equal(kartverketBackoffMs('86400', now), 3_600_000);
assert.equal(
kartverketBackoffMs('Mon, 06 Oct 2026 12:05:00 GMT', now),
300_000,
);
assert.equal(kartverketBackoffMs(null, now), KARTVERKET_FAILURE_BACKOFF_MS);
assert.equal(kartverketBackoffMs('soon', now), KARTVERKET_FAILURE_BACKOFF_MS);
});
test('heights are cached, and misses are not asked again until they expire', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const asked = [];
t.mock.method(globalThis, 'fetch', async (url) => {
asked.push(sentPoints(url).length);
return kartverketResponse(url, (x) => (x < 7.002 ? 1043.26 : null));
});
const cameras = [cameraAt(0), cameraAt(1), cameraAt(5)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 2);
assert.deepEqual(
cameras.map((c) => c.groundElevationM),
[1043.3, 1043.3, 150],
);
// The next refresh asks nothing: heights and the miss are both cached.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(1), cameraAt(5)], {
now,
}),
2,
);
assert.deepEqual(asked, [3]);
// Once the miss expires, only that point is asked again.
clock += KARTVERKET_NULL_TTL_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0), cameraAt(5)], { now });
assert.deepEqual(asked, [3, 1]);
});
test('after a failure no lookup starts until the backoff ends', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('offline');
});
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 1);
// The default backoff outlasts the catalog refresh, so the next refresh
// and every one inside the backoff cost nothing.
assert.ok(KARTVERKET_FAILURE_BACKOFF_MS > CCTV_SOURCE_CACHE_MS);
clock += CCTV_SOURCE_CACHE_MS;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
clock += KARTVERKET_FAILURE_BACKOFF_MS - CCTV_SOURCE_CACHE_MS - 1;
assert.equal(await applyVegvesenGroundHeights([cameraAt(0)], { now }), 0);
assert.equal(fetchMock.mock.callCount(), 1);
// After it, the failed point is asked again.
clock += 2;
fetchMock.mock.mockImplementation(async (url) =>
kartverketResponse(url, () => 321),
);
const later = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(later, { now }), 1);
assert.equal(later[0].groundElevationM, 321);
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a 429 backs off for its Retry-After, not the default', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
const retryAfterS = 50 * 60;
const fetchMock = t.mock.method(
globalThis,
'fetch',
async () =>
new Response('busy', {
status: 429,
headers: { 'Retry-After': String(retryAfterS) },
}),
);
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += KARTVERKET_FAILURE_BACKOFF_MS + 1;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 1);
clock += retryAfterS * 1000 - KARTVERKET_FAILURE_BACKOFF_MS;
await applyVegvesenGroundHeights([cameraAt(0)], { now });
assert.equal(fetchMock.mock.callCount(), 2);
});
test('a lookup cut short by its deadline resumes on the next refresh', async (t) => {
withHeightsEnv(t);
let clock = 1_000_000;
const now = () => clock;
t.mock.method(globalThis, 'fetch', hangingFetch);
// The lookup runs out of time; that is not a failing service.
assert.equal(
await applyVegvesenGroundHeights([cameraAt(0)], {
now,
timeoutMs: 10,
waitMs: 1000,
}),
0,
);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 64),
);
clock += CCTV_SOURCE_CACHE_MS;
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras, { now }), 1);
assert.equal(cameras[0].groundElevationM, 64);
assert.equal(fetchMock.mock.callCount(), 1);
});
test('overlapping refreshes share one lookup', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 3);
});
await Promise.all([
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 }),
]);
assert.equal(fetchMock.mock.callCount(), 1);
release();
});
test('a warm cache does not wait for a few new cameras', async (t) => {
withHeightsEnv(t);
const cameras = Array.from({ length: 20 }, (_, i) => cameraAt(i));
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 500),
);
assert.equal(await applyVegvesenGroundHeights(cameras), 20);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 600);
});
const refresh = [...cameras, cameraAt(20)];
const started = Date.now();
assert.equal(
await applyVegvesenGroundHeights(refresh, { waitMs: 5_000 }),
20,
);
assert.ok(Date.now() - started < 1000);
assert.equal(fetchMock.mock.callCount(), 1);
// The background lookup fills the cache for the next refresh.
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [...cameras, cameraAt(20)];
assert.equal(await applyVegvesenGroundHeights(next), 21);
assert.equal(next[20].groundElevationM, 600);
});
test('cameras without numeric coordinates are skipped, never thrown on', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 9),
);
const cameras = [
{ id: 'no-coordinates' },
null,
{ id: 'strings', lat: '60', lon: '7' },
cameraAt(0),
];
assert.equal(await applyVegvesenGroundHeights(cameras), 1);
assert.equal(cameras[3].groundElevationM, 9);
});
test('a lookup started before a cache clear cannot write after it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 111);
});
await applyVegvesenGroundHeights([cameraAt(0)], { waitMs: 5 });
clearKartverketHeightCache();
t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 222),
);
const fresh = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(fresh), 1);
assert.equal(fresh[0].groundElevationM, 222);
release();
await new Promise((resolve) => setTimeout(resolve, 20));
const later = [cameraAt(0)];
await applyVegvesenGroundHeights(later, { waitMs: 0 });
assert.equal(later[0].groundElevationM, 222);
});
test('CCTV_VEGVESEN_HEIGHTS=0 skips the lookup', async (t) => {
withHeightsEnv(t);
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) =>
kartverketResponse(url, () => 5),
);
process.env.CCTV_VEGVESEN_HEIGHTS = '0';
const cameras = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(cameras), 0);
assert.equal(cameras[0].groundElevationM, 150);
assert.equal(fetchMock.mock.callCount(), 0);
});
test('a slow lookup does not hold the catalog; the next refresh applies it', async (t) => {
withHeightsEnv(t);
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async (url) => {
await gate;
return kartverketResponse(url, () => 812);
});
const first = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(first, { waitMs: 20 }), 0);
assert.equal(first[0].groundElevationM, 150);
release();
// The lookup kept running and filled the cache for the next refresh.
await new Promise((resolve) => setTimeout(resolve, 20));
const next = [cameraAt(0)];
assert.equal(await applyVegvesenGroundHeights(next, { waitMs: 20 }), 1);
assert.equal(next[0].groundElevationM, 812);
});
test('the loader applies Kartverket heights to the cameras it returns', async (t) => {
withHeightsEnv(t);
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return kartverketResponse(url, () => 987.6);
}
return Response.json({ type: 'FeatureCollection', features: [feature()] });
});
const cameras = await loadVegvesenSourcesFromOpenData();
assert.deepEqual(
cameras.map((c) => [c.id, c.groundElevationM]),
[['no-vegvesen-3000063_1', 987.6]],
);
});
test('the loader budgets the height wait from its own start', async (t) => {
withHeightsEnv(t);
const realNow = Date.now.bind(Date);
let offset = 0;
t.mock.method(Date, 'now', () => realNow() + offset);
t.mock.method(globalThis, 'fetch', (url, init) => {
if (String(url).startsWith(KARTVERKET_HEIGHT_URL)) {
return hangingFetch(url, init);
}
// The catalog fetch uses up all but 10 ms of the lane's budget.
offset += KARTVERKET_CATALOG_WAIT_MS - 10;
return Promise.resolve(
Response.json({ type: 'FeatureCollection', features: [feature()] }),
);
});
const started = performance.now();
const cameras = await loadVegvesenSourcesFromOpenData();
assert.ok(performance.now() - started < 1000);
assert.equal(cameras[0].groundElevationM, 150);
});