mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(traffic): flag stale cached TomTom tiles in get_traffic_flow (#1239)
Report stale traffic tiles and their fetch time, and release pending freshness metadata when tile requests fail or are cancelled. Contribution by @quanticsoul4772.
This commit is contained in:
1 parent
f6e7f63a63
commit
6bbf9b94c4
8 files changed
+299
-18
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest
|
||||||
|
tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata
|
||||||
|
released on failed or cancelled tile reads (quanticsoul4772, #1239).
|
||||||
@@ -1701,6 +1701,14 @@ The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
|
|||||||
status, cache or upstream work, refusing cross-site browser requests while
|
status, cache or upstream work, refusing cross-site browser requests while
|
||||||
preserving same-origin app requests and non-browser local callers. The browser
|
preserving same-origin app requests and non-browser local callers. The browser
|
||||||
layers and their rendering remain in their existing modules.
|
layers and their rendering remain in their existing modules.
|
||||||
|
Past the daily tile budget, or while TomTom fails, the proxy answers with its
|
||||||
|
last copy of a tile (`x-tomtom-cache: STALE-BUDGET` / `STALE-ERROR`) however old
|
||||||
|
it is, and every tile carries `x-tomtom-fetched-at`, the proxy fetch time
|
||||||
|
(or the persisted cache file time after restart). The flow source keeps that mark with each decoded tile, so
|
||||||
|
`get_traffic_flow` returns `stale` and `oldest_tile_at` and says when traffic
|
||||||
|
may be out of date instead of presenting cached tiles as live. Pending freshness
|
||||||
|
metadata is released when body reads fail, are cancelled, or exceed their size
|
||||||
|
limit, as well as when a tile decodes or the source resets.
|
||||||
GBFS null or empty availability counts retain an unknown value through station
|
GBFS null or empty availability counts retain an unknown value through station
|
||||||
parsing and rendering; they use the neutral station color rather than reporting
|
parsing and rendering; they use the neutral station color rather than reporting
|
||||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||||
|
|||||||
@@ -182,14 +182,17 @@ export function tomtomProxy() {
|
|||||||
});
|
});
|
||||||
res.end(JSON.stringify(obj));
|
res.end(JSON.stringify(obj));
|
||||||
};
|
};
|
||||||
const sendTile = (buf, cacheStatus) => {
|
// x-tomtom-fetched-at is when the proxy fetched the tile (ms since the
|
||||||
|
// epoch), so a client handed a STALE-* copy can say how old it is.
|
||||||
|
const sendTile = (entry, cacheStatus) => {
|
||||||
if (res.headersSent) return;
|
if (res.headersSent) return;
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'application/x-protobuf',
|
'Content-Type': 'application/x-protobuf',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
'x-tomtom-cache': cacheStatus,
|
'x-tomtom-cache': cacheStatus,
|
||||||
|
'x-tomtom-fetched-at': String(Math.round(entry.at)),
|
||||||
});
|
});
|
||||||
res.end(buf);
|
res.end(entry.buf);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -235,14 +238,14 @@ export function tomtomProxy() {
|
|||||||
}
|
}
|
||||||
// Fresh cache hit — never counts against the budget.
|
// Fresh cache hit — never counts against the budget.
|
||||||
if (entry && now - entry.at < TILE_TTL_MS) {
|
if (entry && now - entry.at < TILE_TTL_MS) {
|
||||||
sendTile(entry.buf, 'HIT');
|
sendTile(entry, 'HIT');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
||||||
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
||||||
if (entry) {
|
if (entry) {
|
||||||
sendTile(entry.buf, 'STALE-BUDGET');
|
sendTile(entry, 'STALE-BUDGET');
|
||||||
} else {
|
} else {
|
||||||
sendJson(429, { error: 'budget' });
|
sendJson(429, { error: 'budget' });
|
||||||
}
|
}
|
||||||
@@ -271,9 +274,9 @@ export function tomtomProxy() {
|
|||||||
}
|
}
|
||||||
const fresh = await inflight.get(key);
|
const fresh = await inflight.get(key);
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
sendTile(fresh.buf, 'MISS');
|
sendTile(fresh, 'MISS');
|
||||||
} else if (entry) {
|
} else if (entry) {
|
||||||
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
|
sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty
|
||||||
} else {
|
} else {
|
||||||
sendJson(502, { error: 'upstream' });
|
sendJson(502, { error: 'upstream' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js';
|
|||||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||||
import { decodeFlowTile } from './flowDecode.js';
|
import { decodeFlowTile } from './flowDecode.js';
|
||||||
|
|
||||||
|
const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the proxy answered a tile from its cache past the refresh window
|
||||||
|
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
|
||||||
|
* fails), and when the proxy fetched it.
|
||||||
|
*/
|
||||||
|
function tileFreshness(response) {
|
||||||
|
const status = response.headers?.get?.('x-tomtom-cache') || '';
|
||||||
|
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
|
||||||
|
return {
|
||||||
|
stale: status.startsWith('STALE'),
|
||||||
|
fetchedAt: fetchedAt > 0 ? fetchedAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
||||||
export function createFlowTileSource({
|
export function createFlowTileSource({
|
||||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
// Freshness travels in response headers, but the tile cache keeps only
|
||||||
|
// decoded segments: hold each response's freshness until its tile decodes,
|
||||||
|
// then key it by the decoded tile, which the cache hands back on a hit.
|
||||||
|
const pending = new Map();
|
||||||
|
const freshness = new WeakMap();
|
||||||
const tiles = createVectorTileSource({
|
const tiles = createVectorTileSource({
|
||||||
template: '/api/tomtom/flow/{z}/{x}/{y}.pbf',
|
template: FLOW_TILE_PATH('{z}', '{x}', '{y}'),
|
||||||
allowedOrigin: 'http://localhost',
|
allowedOrigin: 'http://localhost',
|
||||||
decode: (bytes, z, x, y) =>
|
decode: (bytes, z, x, y) => {
|
||||||
decodeFlowTile(bytes, z, x, y, { strict: true })
|
// Take the mark before decoding, so a tile that fails to decode
|
||||||
|
// leaves nothing behind.
|
||||||
|
const path = FLOW_TILE_PATH(z, x, y);
|
||||||
|
const entry = pending.get(path);
|
||||||
|
const mark = entry?.mark;
|
||||||
|
entry?.release();
|
||||||
|
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
||||||
.filter(isDrivableFlowSegment)
|
.filter(isDrivableFlowSegment)
|
||||||
.flatMap((segment) =>
|
.flatMap((segment) =>
|
||||||
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
||||||
...segment,
|
...segment,
|
||||||
coords,
|
coords,
|
||||||
})),
|
})),
|
||||||
),
|
);
|
||||||
fetchImpl,
|
if (mark) freshness.set(segments, mark);
|
||||||
|
return segments;
|
||||||
|
},
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
const response = await fetchImpl(url, init);
|
||||||
|
if (response.ok && !init.signal.aborted) {
|
||||||
|
const path = String(url);
|
||||||
|
const entry = {
|
||||||
|
mark: tileFreshness(response),
|
||||||
|
release() {
|
||||||
|
if (pending.get(path) === entry) pending.delete(path);
|
||||||
|
init.signal.removeEventListener('abort', entry.release);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
pending.get(path)?.release();
|
||||||
|
pending.set(path, entry);
|
||||||
|
// The vector source aborts this signal for failed/cancelled body reads,
|
||||||
|
// including size-limit failures that never reach decode.
|
||||||
|
init.signal.addEventListener('abort', entry.release, { once: true });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
ttlMs: 120_000,
|
ttlMs: 120_000,
|
||||||
maxTiles: 16,
|
maxTiles: 16,
|
||||||
});
|
});
|
||||||
let partial = false;
|
let partial = false;
|
||||||
/**
|
/**
|
||||||
* Flow segments for bounds, with whether any covering tile failed to load
|
* Flow segments for bounds, with whether any covering tile failed to load
|
||||||
* for this request.
|
* for this request, whether any came from the proxy's stale cache, and when
|
||||||
|
* the oldest tile was fetched.
|
||||||
*/
|
*/
|
||||||
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
||||||
const result = await tiles.fetchBounds(bounds, {
|
const result = await tiles.fetchBounds(bounds, {
|
||||||
@@ -44,7 +93,16 @@ export function createFlowTileSource({
|
|||||||
coords,
|
coords,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
return { segments, partial: result.partial === true };
|
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
||||||
|
const times = marks
|
||||||
|
.map((mark) => mark.fetchedAt)
|
||||||
|
.filter((at) => Number.isFinite(at));
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
partial: result.partial === true,
|
||||||
|
stale: marks.some((mark) => mark.stale === true),
|
||||||
|
oldestTileAt: times.length ? Math.min(...times) : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fetchFlowDetail,
|
fetchFlowDetail,
|
||||||
@@ -52,6 +110,9 @@ export function createFlowTileSource({
|
|||||||
return (await fetchFlowDetail(bounds, options)).segments;
|
return (await fetchFlowDetail(bounds, options)).segments;
|
||||||
},
|
},
|
||||||
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
||||||
resetFlowTileCache: () => tiles.clear(),
|
resetFlowTileCache: () => {
|
||||||
|
for (const entry of pending.values()) entry.release();
|
||||||
|
tiles.clear();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { getEventListeners } from 'node:events';
|
||||||
|
import { createFlowTileSource } from './flowSource.js';
|
||||||
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js';
|
||||||
import { decodeFlowTile } from './flowDecode.js';
|
import { decodeFlowTile } from './flowDecode.js';
|
||||||
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
|
import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js';
|
||||||
@@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti
|
|||||||
assert.ok(publicUrls.length >= 4);
|
assert.ok(publicUrls.length >= 4);
|
||||||
assert.ok(apiUrls.length >= 4);
|
assert.ok(apiUrls.length >= 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => {
|
||||||
|
const fetchedAt = Date.UTC(2026, 9, 16, 8);
|
||||||
|
const reply = (status, at) => async () =>
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) },
|
||||||
|
});
|
||||||
|
const stale = createTrafficSource({
|
||||||
|
fetchImpl: reply('STALE-BUDGET', fetchedAt),
|
||||||
|
});
|
||||||
|
const first = await stale.fetchFlowDetail(bounds);
|
||||||
|
assert.ok(first.segments.length > 0);
|
||||||
|
assert.equal(first.stale, true);
|
||||||
|
assert.equal(first.oldestTileAt, fetchedAt);
|
||||||
|
// The decoded tile is served from cache now, and keeps its mark.
|
||||||
|
const again = await stale.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(stale.getFlowSessionStats().tilesFetched, 1);
|
||||||
|
assert.equal(again.stale, true);
|
||||||
|
assert.equal(again.oldestTileAt, fetchedAt);
|
||||||
|
|
||||||
|
const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) });
|
||||||
|
const live = await fresh.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(live.stale, false);
|
||||||
|
assert.equal(live.oldestTileAt, fetchedAt);
|
||||||
|
|
||||||
|
const unmarked = createTrafficSource({
|
||||||
|
fetchImpl: async () => new Response(fixture),
|
||||||
|
});
|
||||||
|
const plain = await unmarked.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(plain.stale, false);
|
||||||
|
assert.equal(plain.oldestTileAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture constructor-owned Maps only while constructing the source, so tests
|
||||||
|
// can assert retained metadata without adding a diagnostic API to production.
|
||||||
|
function flowSourceWithMaps(fetchImpl) {
|
||||||
|
const maps = [];
|
||||||
|
const NativeMap = globalThis.Map;
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
globalThis.Map = class extends NativeMap {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
maps.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source = createFlowTileSource({ fetchImpl });
|
||||||
|
} finally {
|
||||||
|
globalThis.Map = NativeMap;
|
||||||
|
}
|
||||||
|
return { source, pending: maps[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('flow freshness is released after body failures, size rejection, decode and reset', async () => {
|
||||||
|
for (const failure of ['body', 'size', 'decode']) {
|
||||||
|
let requestSignal;
|
||||||
|
let fail = true;
|
||||||
|
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
|
||||||
|
requestSignal = signal;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({
|
||||||
|
'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS',
|
||||||
|
'x-tomtom-fetched-at': fail ? '1000' : '2000',
|
||||||
|
...(fail && failure === 'size'
|
||||||
|
? { 'content-length': '99999999' }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
async arrayBuffer() {
|
||||||
|
if (fail && failure === 'body') throw new Error('body disconnected');
|
||||||
|
return fail && failure === 'decode' ? new Uint8Array([255]) : fixture;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await assert.rejects(source.fetchFlowDetail(bounds));
|
||||||
|
assert.equal(pending.size, 0, `${failure} leaves no pending metadata`);
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
|
||||||
|
fail = false;
|
||||||
|
const fresh = await source.fetchFlowDetail(bounds);
|
||||||
|
assert.equal(fresh.stale, false);
|
||||||
|
assert.equal(fresh.oldestTileAt, 2000);
|
||||||
|
assert.equal(pending.size, 0, 'successful decode releases metadata');
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
source.resetFlowTileCache();
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => {
|
||||||
|
let resolveOld;
|
||||||
|
let oldSignal;
|
||||||
|
let calls = 0;
|
||||||
|
const { source, pending } = flowSourceWithMaps((url, { signal }) => {
|
||||||
|
if (++calls === 1) {
|
||||||
|
oldSignal = signal;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveOld = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const old = source.fetchFlowDetail(bounds);
|
||||||
|
const rejected = assert.rejects(old, { name: 'AbortError' });
|
||||||
|
while (!resolveOld) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
source.resetFlowTileCache();
|
||||||
|
const fresh = await source.fetchFlowDetail(bounds);
|
||||||
|
resolveOld(
|
||||||
|
new Response(fixture, {
|
||||||
|
headers: {
|
||||||
|
'x-tomtom-cache': 'STALE-ERROR',
|
||||||
|
'x-tomtom-fetched-at': '1000',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await rejected;
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
assert.equal(getEventListeners(oldSignal, 'abort').length, 0);
|
||||||
|
assert.equal(fresh.stale, false);
|
||||||
|
assert.equal(fresh.oldestTileAt, 2000);
|
||||||
|
assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelling a flow body releases its freshness before a new request', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let requestSignal;
|
||||||
|
const { source, pending } = flowSourceWithMaps(async (url, { signal }) => {
|
||||||
|
requestSignal = signal;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }),
|
||||||
|
async arrayBuffer() {
|
||||||
|
assert.equal(pending.size, 1);
|
||||||
|
controller.abort();
|
||||||
|
return fixture;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
source.fetchFlowDetail(bounds, { signal: controller.signal }),
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
);
|
||||||
|
assert.equal(pending.size, 0);
|
||||||
|
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||||
|
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
|
||||||
|
});
|
||||||
@@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o
|
|||||||
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
|
test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => {
|
||||||
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
|
isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' });
|
||||||
let now = Date.UTC(2026, 8, 12, 12);
|
let now = Date.UTC(2026, 8, 12, 12);
|
||||||
|
const fetchedAt = String(now);
|
||||||
t.mock.method(Date, 'now', () => now);
|
t.mock.method(Date, 'now', () => now);
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
t.mock.method(globalThis, 'fetch', async (raw) => {
|
t.mock.method(globalThis, 'fetch', async (raw) => {
|
||||||
@@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
|
|||||||
assert.equal(json(await request('/status')).dailyCount, 1);
|
assert.equal(json(await request('/status')).dailyCount, 1);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
now += 120001;
|
now += 120001;
|
||||||
assert.equal(
|
const staleTile = await request('/flow/8/1/1.pbf');
|
||||||
(await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],
|
assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET');
|
||||||
'STALE-BUDGET',
|
// The stale copy still says when TomTom produced it.
|
||||||
);
|
assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt);
|
||||||
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
|
assert.equal((await request('/flow/8/2/1.pbf')).status, 429);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
now += 86400000;
|
now += 86400000;
|
||||||
|
|||||||
@@ -473,11 +473,22 @@ export const getTrafficFlow = defineTool({
|
|||||||
: `No live traffic is reported in ${area.label}.`) +
|
: `No live traffic is reported in ${area.label}.`) +
|
||||||
(flow.partial
|
(flow.partial
|
||||||
? ' Some map tiles did not load, so these figures are partial.'
|
? ' Some map tiles did not load, so these figures are partial.'
|
||||||
|
: '') +
|
||||||
|
// Past the daily TomTom budget, or while TomTom fails, the proxy
|
||||||
|
// serves its last copy of a tile however old it is.
|
||||||
|
(flow.stale
|
||||||
|
? ' Some tiles are cached copies' +
|
||||||
|
(isoTime(flow.oldestTileAt)
|
||||||
|
? `, the oldest from ${isoTime(flow.oldestTileAt)}`
|
||||||
|
: '') +
|
||||||
|
', so traffic may be out of date.'
|
||||||
: ''),
|
: ''),
|
||||||
data: {
|
data: {
|
||||||
view: suggestView(services, { area, layers: ['traffic'] }),
|
view: suggestView(services, { area, layers: ['traffic'] }),
|
||||||
area: area.label,
|
area: area.label,
|
||||||
partial: flow.partial,
|
partial: flow.partial,
|
||||||
|
stale: flow.stale === true,
|
||||||
|
oldest_tile_at: isoTime(flow.oldestTileAt),
|
||||||
speed_pct_of_free_flow: speedPct,
|
speed_pct_of_free_flow: speedPct,
|
||||||
measured_km: round(measuredKm, 1),
|
measured_km: round(measuredKm, 1),
|
||||||
congested_km: round(congestedKm, 1),
|
congested_km: round(congestedKm, 1),
|
||||||
|
|||||||
@@ -555,6 +555,47 @@ test('traffic counts only roads inside a radius area and reports missing tiles',
|
|||||||
assert.match(partial.summary, /Some map tiles did not load/);
|
assert.match(partial.summary, /Some map tiles did not load/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('traffic says when the proxy served cached tiles, and how old the oldest is', async () => {
|
||||||
|
const road = [
|
||||||
|
{
|
||||||
|
coords: [
|
||||||
|
[-0.2, 0],
|
||||||
|
[0.2, 0],
|
||||||
|
],
|
||||||
|
trafficLevel: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const call = (detail) =>
|
||||||
|
composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
traffic: {
|
||||||
|
getStatus: async () => ({ hasKey: true }),
|
||||||
|
fetchFlowDetail: async () => ({
|
||||||
|
segments: road,
|
||||||
|
partial: false,
|
||||||
|
...detail,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).call('get_traffic_flow', { area: { lat: 0, lon: 0, radius_km: 10 } });
|
||||||
|
const oldest = Date.UTC(2026, 9, 16, 8);
|
||||||
|
const stale = await call({ stale: true, oldestTileAt: oldest });
|
||||||
|
assert.equal(stale.data.stale, true);
|
||||||
|
assert.equal(stale.data.oldest_tile_at, '2026-10-16T08:00:00.000Z');
|
||||||
|
assert.match(
|
||||||
|
stale.summary,
|
||||||
|
/cached copies, the oldest from 2026-10-16T08:00:00\.000Z, so traffic may be out of date\./,
|
||||||
|
);
|
||||||
|
const live = await call({ stale: false, oldestTileAt: oldest });
|
||||||
|
assert.equal(live.data.stale, false);
|
||||||
|
assert.doesNotMatch(live.summary, /out of date/);
|
||||||
|
// A source that can't tell (no freshness fields) isn't reported as stale.
|
||||||
|
const unknown = await call({});
|
||||||
|
assert.equal(unknown.data.stale, false);
|
||||||
|
assert.equal(unknown.data.oldest_tile_at, null);
|
||||||
|
});
|
||||||
|
|
||||||
test('a whole-world bike-share search names every system it skipped', async () => {
|
test('a whole-world bike-share search names every system it skipped', async () => {
|
||||||
const empty = { data: { stations: [] } };
|
const empty = { data: { stations: [] } };
|
||||||
const result = await composeCatalog({
|
const result = await composeCatalog({
|
||||||
|
|||||||
Reference in new issue
Block a user