Merge branch 'main' into german/street-level-slim

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-20 13:06:56 +01:00
commit 24ff5c7ea7
30 files changed
+2625 -150

No files matched your search

+222
View File
@@ -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);
}
});
+202
View File
@@ -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);
});
+621 -1
View File
@@ -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);
});
+4 -1
View File
@@ -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',
+28 -1
View File
@@ -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
}
+174
View File
@@ -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);
}
});