mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into german/street-level-slim
This commit is contained in:
30 files changed
+2625
-150
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
@@ -160,3 +161,224 @@ test('a pack trimmed to its nearest cameras reports how many it offered', async
|
||||
assert.equal(kept.available, 5);
|
||||
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
|
||||
});
|
||||
|
||||
/** A CCTV_SOURCES_JSON value with `count` cameras named `<prefix>-<i>`. */
|
||||
const envSources = (prefix, count = 3) =>
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${prefix}-${i}`,
|
||||
name: `${prefix} ${i}`,
|
||||
lat: 30 + i * 0.01,
|
||||
lon: -97,
|
||||
url: `https://example.test/${prefix}-${i}.jpg`,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Let a background refresh that needs no timers run to completion. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
/** Restore process.env to a snapshot taken with `{ ...process.env }`. */
|
||||
const restoreEnv = (saved) => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
};
|
||||
|
||||
test('an expired catalog is served at once while a slow pack refreshes in the background', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
// Live packs run alongside the env pack, so one slow upstream can hold
|
||||
// the refresh open.
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const hung = [];
|
||||
let hang = false;
|
||||
t.mock.method(globalThis, 'fetch', () =>
|
||||
hang
|
||||
? new Promise((_, reject) => {
|
||||
hung.push(() => reject(new Error('upstream gave up')));
|
||||
})
|
||||
: Promise.reject(new Error('offline')),
|
||||
);
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Past the TTL, with every live pack's upstream hanging.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
hang = true;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
let timer;
|
||||
const served = await Promise.race([
|
||||
getSources(),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve('waited'), 200);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
assert.notEqual(
|
||||
served,
|
||||
'waited',
|
||||
'a stale request waited for the background refresh',
|
||||
);
|
||||
assert.deepEqual(
|
||||
served.map((s) => s.id),
|
||||
['old-0', 'old-1', 'old-2'],
|
||||
);
|
||||
|
||||
// Requests during the refresh share it rather than starting another.
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await pause();
|
||||
const inFlight = hung.length;
|
||||
assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams');
|
||||
await getSources();
|
||||
await getSources();
|
||||
await pause();
|
||||
assert.equal(hung.length, inFlight, 'only one refresh ran');
|
||||
|
||||
// When the slow pack gives up, the refreshed catalog replaces the old one.
|
||||
hang = false;
|
||||
for (const giveUp of hung) giveUp();
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const ids = (await getSources()).map((s) => s.id);
|
||||
if (ids[0] === 'new-0') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.deepEqual(
|
||||
(await getSources()).map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed background refresh keeps the catalog, warns once, and waits a TTL to retry', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
process.env.CCTV_PREFER_AUSTIN = '0';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old', 9);
|
||||
process.env.CCTV_MAX_SOURCES = '8';
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
// Nine cameras over a cap of eight make every refresh warn; throwing from
|
||||
// that warning is how this test makes a refresh reject.
|
||||
let failRefresh = false;
|
||||
let failedRefreshes = 0;
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => {
|
||||
const text = args.join(' ');
|
||||
if (failRefresh && text.includes('exceeds cap')) {
|
||||
failedRefreshes += 1;
|
||||
throw new Error('refresh broke');
|
||||
}
|
||||
warnings.push(text);
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
assert.equal(old.length, 8);
|
||||
|
||||
// Past the TTL the refresh throws: the old catalog is still served.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = true;
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1);
|
||||
assert.equal(
|
||||
warnings.filter((w) => w.includes('background catalog refresh failed'))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// The failure is not retried on every request until the next TTL.
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(failedRefreshes, 1, 'a failed refresh was retried at once');
|
||||
|
||||
// After it, a refresh that works replaces the catalog. With no live packs
|
||||
// the refresh finishes before the request that started it returns.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
failRefresh = false;
|
||||
process.env.CCTV_SOURCES_JSON = envSources('new');
|
||||
await getSources();
|
||||
await settle();
|
||||
const fresh = await getSources();
|
||||
assert.deepEqual(
|
||||
fresh.map((s) => s.id),
|
||||
['new-0', 'new-1', 'new-2'],
|
||||
);
|
||||
// Trim notes stay with the list they describe, so a request served the
|
||||
// old list during the refresh never reports the new list's notes.
|
||||
assert.deepEqual(
|
||||
getSources.trimmedPacks(old).map((p) => [p.pack, p.available, p.served]),
|
||||
[['env', 9, 8]],
|
||||
);
|
||||
assert.deepEqual(getSources.trimmedPacks(fresh), []);
|
||||
assert.deepEqual(getSources.trimmedPacks(), []);
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty background refresh keeps the catalog and waits a TTL before fetching again', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
process.env.CCTV_FORCE_AUSTIN = '1';
|
||||
process.env.CCTV_SOURCES_FILE = '/nonexistent/cctv.json';
|
||||
process.env.CCTV_SOURCES_JSON = envSources('old');
|
||||
let now = 1_000_000;
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('offline'));
|
||||
});
|
||||
const getSources = createCctvCatalog({ sourceRoot: '/nonexistent' });
|
||||
const old = await getSources();
|
||||
|
||||
// Past the TTL every pack comes back empty, as in an upstream outage.
|
||||
now += CCTV_SOURCE_CACHE_MS + 1;
|
||||
process.env.CCTV_SOURCES_JSON = '[]';
|
||||
assert.equal(await getSources(), old);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (warnings.some((w) => w.includes('source refresh returned empty')))
|
||||
break;
|
||||
await settle();
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes('serving 3 stale cameras')),
|
||||
'the empty refresh kept the old catalog',
|
||||
);
|
||||
|
||||
// Until the next TTL, requests are served without touching any upstream.
|
||||
const fetchesAfterRefresh = fetches;
|
||||
assert.equal(await getSources(), old);
|
||||
assert.equal(await getSources(), old);
|
||||
await settle();
|
||||
assert.equal(
|
||||
fetches,
|
||||
fetchesAfterRefresh,
|
||||
'an empty refresh reran at once',
|
||||
);
|
||||
} finally {
|
||||
restoreEnv(saved);
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_LOCATIONS_URL,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
loadTarkteeSourcesFromDatex,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
@@ -84,3 +96,193 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
|
||||
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||
quiet(t);
|
||||
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="loc-1" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4370</latitude><longitude>24.7530</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
<predefinedLocation id="loc-2" version="0">
|
||||
<predefinedLocationName><values><value lang="et">Tallinn Ringtee duplicate</value></values></predefinedLocationName>
|
||||
<location><pointByCoordinates><pointCoordinates><latitude>59.4371</latitude><longitude>24.7531</longitude></pointCoordinates></pointByCoordinates></location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
const dupImagesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1"><linearTrafficView id="1"><linearPredefinedLocationReference id="loc-1"/><trafficViewRecord id="1-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_1.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
<trafficView id="2"><linearTrafficView id="2"><linearPredefinedLocationReference id="loc-2"/><trafficViewRecord id="2-1"><urlLink><urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_2.jpg</urlLinkAddress></urlLink></trafficViewRecord></linearTrafficView></trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const seen = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init?.redirect]);
|
||||
const body =
|
||||
String(url) === TARKTEE_LOCATIONS_URL ? dupLocationsXml : dupImagesXml;
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(seen, [
|
||||
[TARKTEE_LOCATIONS_URL, 'manual'],
|
||||
[TARKTEE_IMAGES_URL, 'manual'],
|
||||
]);
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'ee-tarktee-42');
|
||||
assert.equal(cameras[0].name, 'Tallinn Ringtee');
|
||||
});
|
||||
|
||||
test('Tarktee loader refuses a redirect and cancels both response bodies', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = { locations: false, images: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
if (isLocations) cancelled.locations = true;
|
||||
else cancelled.images = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: isLocations ? 302 : 200,
|
||||
headers: isLocations ? { location: 'https://evil.example/loc.xml' } : {},
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(cancelled.locations, true, 'redirected body is cancelled');
|
||||
assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled');
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => {
|
||||
for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const delayedSibling of [false, true]) {
|
||||
await t.test(
|
||||
`${failedUrl}, delayed sibling: ${delayedSibling}`,
|
||||
async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) === failedUrl) throw new TypeError('fetch failed');
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
return response;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
cancelled,
|
||||
true,
|
||||
'fulfilled sibling body is cancelled',
|
||||
);
|
||||
} finally {
|
||||
await response.body.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
let siblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
siblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
siblingCancelled,
|
||||
true,
|
||||
'sibling 200 stream is cancelled on 503',
|
||||
);
|
||||
|
||||
let overflowSiblingCancelled = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const isLocations = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
if (isLocations) {
|
||||
const padded =
|
||||
SAMPLE_LOCATIONS_XML + ' '.repeat(TARKTEE_MAX_CATALOG_BYTES);
|
||||
return new Response(padded, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': String(padded.length) },
|
||||
});
|
||||
}
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(SAMPLE_IMAGES_XML));
|
||||
},
|
||||
cancel() {
|
||||
overflowSiblingCancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(
|
||||
overflowSiblingCancelled,
|
||||
true,
|
||||
'sibling stream is cancelled when the other feed exceeds the byte cap',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
@@ -2,11 +2,24 @@ 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';
|
||||
@@ -104,6 +117,8 @@ test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
});
|
||||
|
||||
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 = [];
|
||||
@@ -127,7 +142,11 @@ test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// 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),
|
||||
@@ -200,3 +219,604 @@ test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 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);
|
||||
});
|
||||
@@ -293,7 +293,10 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
'distributed by Statens vegvesen · camera ground heights © ' +
|
||||
'<a href="https://kartverket.no/en/api-and-data/terrengdata" target="_blank" rel="noopener">Kartverket</a> ' +
|
||||
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
|
||||
'modified: heights over water set to 0 m and rounded to 0.1 m',
|
||||
},
|
||||
{
|
||||
key: 'nyc-cctv',
|
||||
|
||||
@@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||
}
|
||||
|
||||
/** Signed distance along the great circle from the origin toward the destination. */
|
||||
function alongTrackKm(position, origin, destination) {
|
||||
const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM;
|
||||
const direction =
|
||||
bearingRad(origin.lat, origin.lon, position.lat, position.lon) -
|
||||
bearingRad(origin.lat, origin.lon, destination.lat, destination.lon);
|
||||
return (
|
||||
Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) *
|
||||
EARTH_RADIUS_KM
|
||||
);
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
const routeKm = greatCircleKm(origin, destination);
|
||||
const alongKm = alongTrackKm(position, origin, destination);
|
||||
// atan2 returns the principal arc in [-πR, πR]. Near an antipodal
|
||||
// destination, its allowed forward extension can cross that branch cut.
|
||||
const segmentAlongKm =
|
||||
alongKm < -CROSS_TRACK_KM
|
||||
? alongKm + 2 * Math.PI * EARTH_RADIUS_KM
|
||||
: alongKm;
|
||||
// Coincident or antipodal endpoints do not define one minor arc. Keep the
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -76,7 +100,10 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
) < CROSS_TRACK_KM &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
import { createTracking } from '../layers/flights/tracking.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||
const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 };
|
||||
const behind = { lat: 35.818227816411174, lon: 113.83352603632906 };
|
||||
const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 };
|
||||
const midpoint = { lat: 35.8, lon: -120.4 };
|
||||
|
||||
const plausible = (position, origin = SFO, destination = LAX) =>
|
||||
routePlausible({
|
||||
latDeg: position.lat,
|
||||
lonDeg: position.lon,
|
||||
altitudeM: 10000,
|
||||
verticalRateMps: 0,
|
||||
origin,
|
||||
destination,
|
||||
});
|
||||
|
||||
// Independent spherical oracle: native unit-vector angular distances, without
|
||||
// the production bearing/cross-track/along-track calculations.
|
||||
const unit = ({ lat, lon }) => {
|
||||
const latitude = Cesium.Math.toRadians(lat);
|
||||
const longitude = Cesium.Math.toRadians(lon);
|
||||
return new Cesium.Cartesian3(
|
||||
Math.cos(latitude) * Math.cos(longitude),
|
||||
Math.cos(latitude) * Math.sin(longitude),
|
||||
Math.sin(latitude),
|
||||
);
|
||||
};
|
||||
const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b));
|
||||
const excess = (position, origin, destination) =>
|
||||
angle(origin, position) +
|
||||
angle(position, destination) -
|
||||
angle(origin, destination);
|
||||
|
||||
test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => {
|
||||
assert.ok(excess(beyond, SFO, LAX) > 2.9);
|
||||
assert.equal(plausible(beyond), false);
|
||||
});
|
||||
|
||||
test('backwards and antipodal extensions are not the scheduled airport leg', () => {
|
||||
for (const position of [behind, antipode]) {
|
||||
assert.ok(excess(position, SFO, LAX) > 3);
|
||||
assert.equal(plausible(position), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary route offsets and generous endpoint extensions stay plausible', () => {
|
||||
assert.ok(excess(midpoint, SFO, LAX) < 0.001);
|
||||
for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) {
|
||||
assert.equal(plausible(position), true);
|
||||
}
|
||||
// ~167 km past either endpoint is outside the 130 km endpoint circle,
|
||||
// but remains inside the 200 km coarse-route tolerance.
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true);
|
||||
assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true);
|
||||
});
|
||||
|
||||
test('the generous endpoint tolerance still excludes a clearly different leg', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false);
|
||||
assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false);
|
||||
});
|
||||
|
||||
test('minor-arc admission works across the antimeridian and near the pole', () => {
|
||||
const dateline = [
|
||||
{ lat: 10, lon: 170 },
|
||||
{ lat: 10, lon: -170 },
|
||||
];
|
||||
assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true);
|
||||
assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false);
|
||||
const polar = [
|
||||
{ lat: 80, lon: -60 },
|
||||
{ lat: 80, lon: 60 },
|
||||
];
|
||||
const top = { lat: 84.96163122670251, lon: 0 };
|
||||
assert.ok(excess(top, ...polar) < 1e-8);
|
||||
assert.equal(plausible(top, ...polar), true);
|
||||
assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false);
|
||||
});
|
||||
|
||||
test('the forward tolerance survives the signed-angle branch cut in both directions', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
for (const direction of [-1, 1]) {
|
||||
const destination = { lat: 0, lon: direction * 179.9 };
|
||||
const within = { lat: 0, lon: direction * -178.8 };
|
||||
const endpointGapKm = angle(destination, within) * 6371.0088;
|
||||
assert.ok(endpointGapKm > 130 && endpointGapKm < 200);
|
||||
assert.equal(plausible(within, origin, destination), true);
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: direction * -178 }, origin, destination),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => {
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(plausible(beyond, SFO, null), true);
|
||||
assert.equal(plausible(beyond, null, null), true);
|
||||
});
|
||||
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||
const collection = new Cesium.BillboardCollection();
|
||||
t.after(() => collection.destroy());
|
||||
const flightState = {
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
feed: { _backoff: false },
|
||||
_billboards: new Map(),
|
||||
_scratchCarto: new Cesium.Cartographic(),
|
||||
};
|
||||
// Only application host services are supplied. Route admission, WGS84
|
||||
// billboard conversion and tracked-label formatting are the actual owners.
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'trails',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'camera',
|
||||
'picking',
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
[midpoint, true],
|
||||
]) {
|
||||
const id = 'abc001';
|
||||
const route = { origin: SFO, destination: LAX };
|
||||
flightState.records.data.set(id, {
|
||||
callsign: 'OWNED001',
|
||||
altitude: 10000,
|
||||
verticalRate: 0,
|
||||
route,
|
||||
});
|
||||
flightState._billboards.set(
|
||||
id,
|
||||
collection.add({
|
||||
id,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
10000,
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(tracking._routeIsPlausible(id, route), expected);
|
||||
}
|
||||
});
|
||||
@@ -139,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
});
|
||||
layerState._points.set(noradId, point);
|
||||
layerState._denseIds.push(noradId);
|
||||
layerState._denseStale ||= Boolean(res.stale);
|
||||
added++;
|
||||
}
|
||||
// Yield to the event loop between chunks.
|
||||
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
layerState._catalog.delete(noradId);
|
||||
}
|
||||
layerState._denseIds = [];
|
||||
layerState._denseStale = false;
|
||||
layerState._denseCursor = 0;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
|
||||
Vendored
+5
-2
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const stale = Boolean(layerState._coreStale || layerState._denseStale);
|
||||
return {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
stale: false,
|
||||
stale,
|
||||
status:
|
||||
layerState._lastError === 'CelesTrak unreachable'
|
||||
? 'unavailable'
|
||||
: layerState._lastError
|
||||
? 'degraded'
|
||||
: 'nominal',
|
||||
: stale
|
||||
? 'stale'
|
||||
: 'nominal',
|
||||
error: layerState._lastError,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -36,7 +36,12 @@ export function createIngestion({
|
||||
if (!res.ok) return { ...groupDef, entries: [], ok: false };
|
||||
const entries = parts.orbits.parseTLE(res.text);
|
||||
updateSignal.throwIfAborted();
|
||||
return { ...groupDef, entries, ok: entries.length > 0 };
|
||||
return {
|
||||
...groupDef,
|
||||
entries,
|
||||
ok: entries.length > 0,
|
||||
stale: Boolean(res.stale),
|
||||
};
|
||||
} catch (error) {
|
||||
if (updateSignal.aborted || error?.name === 'AbortError')
|
||||
throw error;
|
||||
@@ -91,6 +96,7 @@ export function createIngestion({
|
||||
// catalog or those labels stay stale for the life of the session.
|
||||
layerState._detectionObjects.clear();
|
||||
layerState._denseIds = [];
|
||||
layerState._denseStale = false;
|
||||
layerState._denseCursor = 0;
|
||||
layerState._denseLoadController?.abort();
|
||||
layerState._denseLoadController = null;
|
||||
@@ -102,11 +108,13 @@ export function createIngestion({
|
||||
// Process all TLE entries in CATALOG_GROUPS order
|
||||
const allEntries = [];
|
||||
for (const r of results) {
|
||||
for (const e of r.entries) allEntries.push({ ...e, group: r.tag });
|
||||
for (const e of r.entries)
|
||||
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
||||
}
|
||||
|
||||
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
||||
const seen = new Set();
|
||||
let coreStale = false;
|
||||
|
||||
for (const entry of allEntries) {
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
@@ -123,6 +131,8 @@ export function createIngestion({
|
||||
group: entry.group,
|
||||
});
|
||||
|
||||
coreStale ||= entry.stale;
|
||||
|
||||
// Propagate initial position
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
@@ -157,6 +167,7 @@ export function createIngestion({
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
|
||||
layerState._coreStale = coreStale;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState._lastUpdate = Date.now();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function createLifecycle({
|
||||
layerState._orbitPaths = new Map();
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._coreStale = false;
|
||||
layerState._denseStale = false;
|
||||
layerState._trackedNorad = null;
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
layerState._trackedEntity = null;
|
||||
@@ -162,6 +164,8 @@ export function createLifecycle({
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._coreStale = false;
|
||||
layerState._denseStale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._lastFocusUpdate = 0;
|
||||
layerState._activeFocusCount = 0;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSatelliteSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createLabels } from './labels.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const GROUPS = [
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
];
|
||||
|
||||
function tle(id, invalid = false) {
|
||||
// Owned synthetic catalog IDs over one valid element set; never live requests.
|
||||
const motion = invalid ? ' 0.00000000' : '15.48899044';
|
||||
return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const policy = {
|
||||
stale: new Set(),
|
||||
invalid: new Set(),
|
||||
ids: {},
|
||||
status: 200,
|
||||
hold: false,
|
||||
};
|
||||
const held = new EventEmitter();
|
||||
let heldCount = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
const group = req.url.split('/').at(-1);
|
||||
if (policy.hold) {
|
||||
held.emit('request', ++heldCount);
|
||||
return;
|
||||
}
|
||||
res.writeHead(policy.status, {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS',
|
||||
});
|
||||
res.end(
|
||||
tle(
|
||||
policy.ids[group] ?? 25545 + GROUPS.indexOf(group),
|
||||
policy.invalid.has(group),
|
||||
),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const source = createSatelliteSource({
|
||||
fetchImpl: (path, options) =>
|
||||
fetch(`http://127.0.0.1:${server.address().port}${path}`, options),
|
||||
});
|
||||
// Application host services and native EventTargets; actual Cesium collections,
|
||||
// input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene.
|
||||
const noop = () => {};
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
layerState: { isExplicitLayerStateOrigin: () => false },
|
||||
focus: {
|
||||
clearFocusTarget: noop,
|
||||
publishFocusTargetFromCachedPosition: noop,
|
||||
getFocusTarget: () => null,
|
||||
},
|
||||
context: {
|
||||
clearTrackedSubjectContext: noop,
|
||||
getContextStore: () => ({ entities: new Map() }),
|
||||
refreshTrackedSubjectContext: noop,
|
||||
selectTrackedSubjectContext: noop,
|
||||
},
|
||||
readout: { refreshTrackedReadout: noop },
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
};
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
for (const [name, factory] of Object.entries({
|
||||
controls: createControls,
|
||||
orbits: createOrbits,
|
||||
labels: createLabels,
|
||||
tracking: createTracking,
|
||||
rendering: createRendering,
|
||||
catalog: createCatalog,
|
||||
interaction: createInteraction,
|
||||
lifecycle: createLifecycle,
|
||||
ingestion: createIngestion,
|
||||
})) {
|
||||
parts[name] = factory(context);
|
||||
}
|
||||
const viewer = {
|
||||
scene: {
|
||||
canvas: new EventTarget(),
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
},
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
};
|
||||
await parts.lifecycle.methods.init(viewer);
|
||||
t.after(async () => {
|
||||
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
|
||||
viewer.scene.primitives.destroy();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
return {
|
||||
state,
|
||||
parts,
|
||||
viewer,
|
||||
policy,
|
||||
update: (options) => parts.ingestion.methods.update(viewer, options),
|
||||
stats: () => parts.controls.methods.getStats(),
|
||||
feed: () => layerFeedState(parts.controls.methods.getStats()),
|
||||
async waitHeld(count) {
|
||||
while (heldCount < count)
|
||||
await once(held, 'request', { signal: AbortSignal.timeout(3000) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
assert.equal(f.stats().count, 6);
|
||||
f.policy.stale = new Set(GROUPS.slice(0, 6));
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale = new Set(['visual']);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.ids.visual = 25545;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.state._catalog.get(25545).group, 'stations');
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(
|
||||
f.stats().stale,
|
||||
true,
|
||||
'a later fresh duplicate does not replace the stale winner',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid stale orbital elements are not admitted as provenance', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.invalid.add('visual');
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.state._catalog.has(25546), false);
|
||||
assert.equal(f.stats().stale, false);
|
||||
});
|
||||
|
||||
test('failed and aborted core refreshes keep provenance of retained records', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('stations');
|
||||
await f.update();
|
||||
const priorTime = f.stats().lastUpdate;
|
||||
f.policy.status = 503;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().status, 'unavailable');
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.update({ signal: controller.signal });
|
||||
await f.waitHeld(6);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
assert.equal(f.state._activeUpdateControllers.size, 0);
|
||||
});
|
||||
|
||||
test('dense provenance belongs to its admitted records and clears on removal', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
f.state._params.catalog = 'dense';
|
||||
await f.parts.catalog._loadDenseCatalog();
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(
|
||||
f.feed(),
|
||||
'stale',
|
||||
'removing dense data must not clear core provenance',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
await f.parts.catalog._loadDenseCatalog();
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal });
|
||||
await f.waitHeld(1);
|
||||
controller.abort();
|
||||
assert.equal((await work).status, 'cancelled');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
f.state._params.catalog = 'core';
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('stations');
|
||||
await f.update();
|
||||
f.policy.hold = true;
|
||||
const work = f.update();
|
||||
await f.waitHeld(6);
|
||||
const rejected = assert.rejects(work, { name: 'AbortError' });
|
||||
f.parts.lifecycle.methods.destroy(f.viewer);
|
||||
assert.equal(f.stats().count, 0);
|
||||
assert.equal(f.stats().stale, false);
|
||||
await f.parts.lifecycle.methods.init(f.viewer);
|
||||
await rejected;
|
||||
assert.equal(f.stats().stale, false);
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
@@ -40,6 +40,10 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
// Cache provenance belongs to admitted core and dense records separately.
|
||||
state._coreStale = false;
|
||||
state._denseStale = false;
|
||||
|
||||
state._activeUpdateControllers = new Set();
|
||||
|
||||
state._denseLoadController = null;
|
||||
|
||||
@@ -137,7 +137,7 @@ export class RealtimeTurns {
|
||||
content: [{ type: 'input_text', text: cleanText }],
|
||||
},
|
||||
};
|
||||
this.sendRealtimeEvent(itemEvent, 'client.user_text');
|
||||
if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return;
|
||||
this.requestUserTextResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
|
||||
function fixture({ pointer = null, onUserSend = () => {} } = {}) {
|
||||
const sent = [];
|
||||
const executed = [];
|
||||
const rejectedTexts = new Set();
|
||||
let rejectResponse = false;
|
||||
const controller = new GevRealtimeController({
|
||||
runner: async (name) => {
|
||||
executed.push(name);
|
||||
return { ok: true };
|
||||
},
|
||||
backend: {},
|
||||
debugSink: null,
|
||||
pointer,
|
||||
ui: {
|
||||
root: {
|
||||
dataset: {},
|
||||
classList: { remove() {} },
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
});
|
||||
controller.dc = {
|
||||
readyState: 'open',
|
||||
send(body) {
|
||||
const value = JSON.parse(body);
|
||||
const text = value.item?.role === 'user' && value.item.content?.[0]?.text;
|
||||
if (value.item?.role === 'user') onUserSend();
|
||||
if (
|
||||
rejectedTexts.has(text) ||
|
||||
(rejectResponse && value.type === 'response.create')
|
||||
)
|
||||
throw new Error('Synthetic channel rejection');
|
||||
sent.push(value);
|
||||
},
|
||||
close() {
|
||||
this.readyState = 'closed';
|
||||
},
|
||||
};
|
||||
controller.status = 'listening';
|
||||
controller._turns.narration = null;
|
||||
const event = (payload) =>
|
||||
controller.handleRealtimeEvent({ data: JSON.stringify(payload) });
|
||||
const created = (id) => event({ type: 'response.created', response: { id } });
|
||||
const done = (id) =>
|
||||
event({
|
||||
type: 'response.done',
|
||||
response: { id, status: 'completed', output: [] },
|
||||
});
|
||||
const creates = () =>
|
||||
sent.filter(
|
||||
(x) =>
|
||||
x.type === 'response.create' &&
|
||||
x.response?.metadata?.gev !== 'progress',
|
||||
);
|
||||
const texts = () =>
|
||||
sent
|
||||
.filter((x) => x.item?.role === 'user')
|
||||
.map((x) => x.item.content[0].text);
|
||||
return {
|
||||
controller,
|
||||
sent,
|
||||
executed,
|
||||
rejectedTexts,
|
||||
event,
|
||||
created,
|
||||
done,
|
||||
creates,
|
||||
texts,
|
||||
rejectResponses(value) {
|
||||
rejectResponse = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('rejected idle text schedules no answer, and a successful successor answers once', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.rejectedTexts.add('rejected');
|
||||
assert.equal(f.controller.sendTextCommand('rejected'), undefined);
|
||||
assert.equal(f.creates().length, 0);
|
||||
assert.equal(f.controller.pendingUserTextResponse, false);
|
||||
assert.equal(f.controller.responseCreatePending, false);
|
||||
assert.equal(f.controller.sendTextCommand('successor'), undefined);
|
||||
assert.deepEqual(f.texts(), ['successor']);
|
||||
assert.equal(f.creates().length, 1);
|
||||
await f.created('successor');
|
||||
await f.done('successor');
|
||||
assert.equal(f.creates().length, 1);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejected text behind an active response cannot queue an answer', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.created('active');
|
||||
f.controller.queueResponseCreate('Old tool follow-up');
|
||||
assert.equal(
|
||||
f.controller.pendingResponseInstructions,
|
||||
'Old tool follow-up',
|
||||
);
|
||||
const runningTool = new AbortController();
|
||||
f.controller._turns.activeToolAbortControllers.add(runningTool);
|
||||
f.rejectedTexts.add('rejected');
|
||||
f.controller.sendTextCommand('rejected');
|
||||
assert.equal(f.controller.pendingUserTextResponse, false);
|
||||
assert.equal(f.controller.pendingResponseInstructions, null);
|
||||
assert.equal(f.controller._turns.isSupersededResponse('active'), true);
|
||||
assert.equal(runningTool.signal.aborted, true);
|
||||
const staleCall = {
|
||||
type: 'response.function_call_arguments.done',
|
||||
response_id: 'active',
|
||||
call_id: 'stale-call',
|
||||
name: 'fly_to_location',
|
||||
arguments: '{"query":"Paris"}',
|
||||
};
|
||||
await f.event(staleCall);
|
||||
await f.event(staleCall);
|
||||
assert.deepEqual(f.executed, []);
|
||||
const outputs = f.sent.filter(
|
||||
(x) => x.item?.type === 'function_call_output',
|
||||
);
|
||||
assert.equal(outputs.length, 1);
|
||||
assert.equal(outputs[0].item.call_id, 'stale-call');
|
||||
assert.equal(JSON.parse(outputs[0].item.output).superseded, true);
|
||||
await f.done('active');
|
||||
assert.equal(f.creates().length, 0);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejected text still prepares narration and pointer context before sending', () => {
|
||||
const steps = [];
|
||||
const f = fixture({
|
||||
pointer: {
|
||||
beginTurn() {
|
||||
steps.push('pointer');
|
||||
return null;
|
||||
},
|
||||
clear() {},
|
||||
},
|
||||
onUserSend() {
|
||||
steps.push('send');
|
||||
},
|
||||
});
|
||||
f.controller._turns.narration = {
|
||||
cancel() {
|
||||
steps.push('cancel');
|
||||
},
|
||||
userTurnEnded() {
|
||||
steps.push('user-turn-ended');
|
||||
},
|
||||
};
|
||||
try {
|
||||
f.rejectedTexts.add('rejected');
|
||||
f.controller.sendTextCommand('rejected');
|
||||
assert.deepEqual(steps, ['cancel', 'user-turn-ended', 'pointer', 'send']);
|
||||
assert.equal(f.creates().length, 0);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('earlier accepted deferred input survives a newer failed send and answers once', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.created('active');
|
||||
f.controller.sendTextCommand('accepted');
|
||||
f.rejectedTexts.add('rejected');
|
||||
f.controller.sendTextCommand('rejected');
|
||||
assert.deepEqual(f.texts(), ['accepted']);
|
||||
assert.equal(f.controller.pendingUserTextResponse, true);
|
||||
assert.equal(f.creates().length, 0);
|
||||
await f.done('active');
|
||||
assert.equal(f.creates().length, 1);
|
||||
await f.done('active');
|
||||
assert.equal(f.creates().length, 1);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejected text preserves an outstanding create without adding deferred input', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.controller.sendTextCommand('accepted');
|
||||
f.rejectedTexts.add('rejected');
|
||||
f.controller.sendTextCommand('rejected');
|
||||
assert.equal(f.controller.responseCreatePending, true);
|
||||
assert.equal(f.controller.pendingUserTextResponse, false);
|
||||
await f.created('accepted');
|
||||
await f.done('accepted');
|
||||
assert.equal(f.creates().length, 1);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
for (const acceptedFirst of [false, true]) {
|
||||
test(`progress completion answers only accepted deferred input (accepted first=${acceptedFirst})`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.equal(
|
||||
f.controller._turns.speakProgressLine('Checking the map.'),
|
||||
true,
|
||||
);
|
||||
await f.event({
|
||||
type: 'response.created',
|
||||
response: { id: 'progress', metadata: { gev: 'progress' } },
|
||||
});
|
||||
if (acceptedFirst) f.controller.sendTextCommand('accepted');
|
||||
f.rejectedTexts.add('rejected');
|
||||
f.controller.sendTextCommand('rejected');
|
||||
assert.equal(f.controller.pendingUserTextResponse, acceptedFirst);
|
||||
await f.event({
|
||||
type: 'response.done',
|
||||
response: {
|
||||
id: 'progress',
|
||||
status: 'cancelled',
|
||||
metadata: { gev: 'progress' },
|
||||
},
|
||||
});
|
||||
assert.equal(f.creates().length, acceptedFirst ? 1 : 0);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('accepted text with a rejected response.create releases the pending latch for a successor', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.rejectResponses(true);
|
||||
f.controller.sendTextCommand('accepted without response');
|
||||
assert.equal(f.controller.responseCreatePending, false);
|
||||
assert.equal(f.creates().length, 0);
|
||||
f.rejectResponses(false);
|
||||
f.controller.sendTextCommand('successor');
|
||||
assert.deepEqual(f.texts(), ['accepted without response', 'successor']);
|
||||
assert.equal(f.creates().length, 1);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('successful deferred typed burst still retains both items and coalesces one answer', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await f.created('active');
|
||||
f.controller.sendTextCommand('first');
|
||||
f.controller.sendTextCommand('second');
|
||||
assert.deepEqual(f.texts(), ['first', 'second']);
|
||||
assert.equal(f.creates().length, 0);
|
||||
await f.done('active');
|
||||
await f.done('active');
|
||||
assert.equal(f.creates().length, 1);
|
||||
} finally {
|
||||
f.controller.stop();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user