Merge upstream main into denis/rail-layer

This commit is contained in:
Denis Groshev committed 2026-11-11 23:23:58 +01:00
commit 9b3ca481ce
11 files changed
+406 -35

No files matched your search

+2 -1
View File
@@ -69,6 +69,7 @@ export function createApplicationCatalog({
signal,
metadata = APPLICATION_LAYER_METADATA,
vesselOptions,
newsOptions,
resolveAsset,
nepalBoundaryResolver,
streetLevelProviders,
@@ -143,7 +144,7 @@ export function createApplicationCatalog({
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationNews({ source: sources.news }),
createApplicationNews({ ...newsOptions, source: sources.news }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
+9 -2
View File
@@ -21,7 +21,13 @@ export function createDirectionsSource({
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
// Proxy error pages may not be JSON.
let payload = null;
try {
payload = await response.json();
} catch (error) {
if (response.ok) throw error;
}
signal?.throwIfAborted();
// Failures keep their English message and carry the HTTP status and the
// server's refusal text, so the layer can show them in the language.
@@ -39,7 +45,8 @@ export function createDirectionsSource({
{ status: 429, refusal },
);
}
if (!response.ok && !payload?.error)
// A 5xx body may mislabel an outage as a route refusal.
if (response.status >= 500 || (!response.ok && !payload?.error))
throw Object.assign(
new Error(`Routing unavailable (HTTP ${response.status})`),
{ status: response.status },
+12
View File
@@ -41,3 +41,15 @@ test('route failures are surfaced without fallback requests', async () => {
assert.equal(calls, 1);
}
});
test('a routing outage stays an outage when its body carries an error', async () => {
const source = createDirectionsSource({ fetchImpl: async () =>
Response.json({ ok: false, error: 'routing service unavailable' }, { status: 502 }) });
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
});
test('a routing outage page that is not JSON still reads as an outage', async () => {
const source = createDirectionsSource({ fetchImpl: async () =>
new Response('<html>Bad Gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) });
await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' });
});
+55 -1
View File
@@ -48,6 +48,7 @@ import {
countryGroups,
countryOnly,
disputedLabel,
eventPicture,
feedCardView,
feedQueryFor,
hotspotLabel,
@@ -63,6 +64,7 @@ import {
normalizeFilters,
passesFilters,
reportMarkerPosition,
reportSeverity,
reportsInView,
severityStyle,
shortTitle,
@@ -167,6 +169,13 @@ const emptyInView = () => ({
* chosen: outlines (disputed areas dotted) and the geocoded reports as
* markers, the big events as picture cards. The News panel renders the feed
* this layer publishes through getRowControls().feed.
*
* `pictureCards(cards, { selectedId })`, when given, draws the picture cards
* instead of the layer: it is called on every render with one card per
* pictured event on the map, in rank order (`{ id, title, lat, lon,
* publishedAt, severity, color, image, video, credit }`), and the selected
* report's id or null, whose own card the layer still draws at its marker; it
* is called with no cards when the layer is turned off.
*/
export function createNewsLayer({
source,
@@ -178,6 +187,7 @@ export function createNewsLayer({
openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
getViewCentre = viewCentre,
registerCredit = registerDynamicCredit,
pictureCards: drawPictureCards = null,
} = {}) {
if (
typeof source?.getHotspots !== 'function' ||
@@ -185,6 +195,8 @@ export function createNewsLayer({
)
throw new TypeError('News requires a news source');
if (!overlayHost) throw new TypeError('News requires an overlay host');
if (drawPictureCards !== null && typeof drawPictureCards !== 'function')
throw new TypeError('News picture cards must be a function');
let viewer = null,
dataSource = null,
@@ -679,6 +691,43 @@ export function createNewsLayer({
}
}
// With a `pictureCards` function the host draws the picture cards: one per
// event on the map with a picture (its most severe, then newest report),
// most severe then newest first, at its marker. The host chooses which
// stand; a click on one calls setParams({ reportId, focus: true }).
function hostCards(drawn) {
const events = new Set();
return drawn
.filter((r) => r.pins.length && onMap(r) && eventPicture(r))
.sort(
(a, b) =>
reportSeverity(b) - reportSeverity(a) ||
(b.event?.lastAt || b.publishedAt || '').localeCompare(
a.event?.lastAt || a.publishedAt || '',
),
)
.filter((report) => {
const event = report.event?.id || report.id;
if (events.has(event)) return false;
events.add(event);
return true;
})
.map((report) => {
const picture = eventPicture(report);
return {
id: report.id,
title: report.titleEn || report.title,
...reportMarkerPosition(report, 0),
publishedAt: report.event?.lastAt || report.publishedAt || null,
severity: reportSeverity(report),
color: severityStyle(report).color,
image: picture.url,
video: picture.video || null,
credit: picture.credit,
};
});
}
// The selected report's card: what, where, how attested, the link to the
// original article ("Source: host ↗"), which a click on it opens, and the
// service it came through.
@@ -775,7 +824,11 @@ export function createNewsLayer({
.filter((target) => target.position);
horizon.update();
updatePulse();
pictureCards(drawn, current, labels);
if (drawPictureCards)
drawPictureCards(enabled ? hostCards(drawn) : [], {
selectedId: enabled ? selectedId : null,
});
else pictureCards(drawn, current, labels);
selectedCard(current, labels);
if (enabled)
overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false });
@@ -1442,6 +1495,7 @@ export function createNewsLayer({
}
overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false);
drawPictureCards?.([], { selectedId: null });
requestRender();
notify();
},
+81
View File
@@ -46,6 +46,7 @@ function fixture(
centre = () => ({ lat: 49, lon: 36 }),
credits = [],
now = () => NOW,
pictureCards,
} = {},
) {
const calls = { feed: [], hotspots: 0, outline: 0 };
@@ -118,6 +119,7 @@ function fixture(
credits.push(credit.key);
return true;
},
...(pictureCards ? { pictureCards } : {}),
});
// The context store announces selections on `window`, which Node lacks.
const originalWindow = globalThis.window;
@@ -227,6 +229,85 @@ test('the severity floor changes what is shown, not what is read', async (t) =>
);
});
test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => {
const picture = (url, extra = {}) => ({
size: 1,
lastAt: '2026-10-28T09:00:00.000Z',
sides: [],
image: { url, publisher: 'Example', ...extra },
});
const reports = [
report('single', {
severity: 1,
event: picture('https://example.com/single.jpg', {
video: 'https://example.com/single.mp4',
}),
}),
report('major', {
severity: 3,
event: {
...picture('https://example.com/major.jpg'),
id: 'event-major',
size: 3,
},
}),
// Another report of the same event: its picture stands once.
report('repeat', {
severity: 2,
event: {
...picture('https://example.com/major.jpg'),
id: 'event-major',
size: 3,
},
}),
report('bare', { severity: 2 }),
];
const handed = [];
const { layer, entries } = fixture(t, {
height: 500_000,
source: { getFeed: async () => ({ reports, nextBefore: null }) },
pictureCards: (cards, state) => handed.push({ cards, ...state }),
});
layer.enable();
await layer.update();
layer.setParams({ filters: { minSeverity: 0 } });
const { cards, selectedId } = handed.at(-1);
assert.equal(selectedId, null);
// A single source is enough; a report without a picture has none.
assert.deepEqual(
cards.map((card) => card.id),
['major', 'single'],
);
assert.equal(cards[1].image, 'https://example.com/single.jpg');
assert.equal(cards[1].video, 'https://example.com/single.mp4');
assert.equal(cards[0].video, null);
assert.equal(cards[0].credit, 'Example');
assert.ok(
Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5,
);
// The layer paints none of its own.
assert.ok(
entries.flat().every((entry) => !entry.id.startsWith('event-card:')),
);
// The selected report is named, pictured or not.
layer.setParams({ reportId: 'bare' });
assert.equal(handed.at(-1).selectedId, 'bare');
layer.disable();
assert.deepEqual(handed.at(-1), { cards: [], selectedId: null });
});
test('picture cards must be drawn by a function', () => {
assert.throws(
() =>
createNewsLayer({
source: { getHotspots() {}, getFeed() {} },
overlayHost: {},
pictureCards: 'yes',
}),
TypeError,
);
});
test('Local reads every report once, lists those near the view and takes the map over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let centre = { lat: 49, lon: 36 };
+97 -12
View File
@@ -236,23 +236,108 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be
assert.equal((await request(route, url)).body.error, error);
assert.equal(calls, 1);
now += 600001;
t.mock.method(
globalThis,
'fetch',
async () => new Response('offline', { status: 503 }),
t.mock.method(globalThis, 'fetch', async () =>
Response.json(
{ code: 'NoRoute', message: 'Impossible route between points' },
{ status: 400 },
),
);
assert.deepEqual((await request(route, query)).body, {
ok: false,
error: 'no route found',
});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { headers: { 'content-type': 'text/html' } }),
const noRoute = await request(route, query);
assert.equal(noRoute.statusCode, 200);
assert.deepEqual(noRoute.body, { ok: false, error: 'no route found' });
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ code: 'NoRoute', routes: [] }),
);
assert.equal((await request(route, query)).body.error, 'no route found');
});
test('a routing outage is reported as one, not as "no route found"', async (t) => {
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01';
for (const response of [
() => new Response('offline', { status: 503 }),
() => new Response('blocked', { status: 403 }),
() => new Response('x', { headers: { 'content-type': 'text/html' } }),
() =>
new Response('<html>', {
headers: { 'content-type': 'application/json' },
}),
() => Promise.reject(new TypeError('fetch failed')),
() => Promise.reject(new DOMException('aborted', 'AbortError')),
]) {
_resetRouteUpstreamForTest();
const request = install(installRouteMiddleware);
t.mock.method(globalThis, 'fetch', async () => response());
const result = await request(route, query);
assert.equal(result.statusCode, 502);
assert.deepEqual(result.body, {
ok: false,
error: 'routing service unavailable',
});
}
});
test('callers sharing one failed upstream call all hear the outage, and it is not cached', async (t) => {
_resetRouteUpstreamForTest();
_setRouteUpstreamIntervalForTest(0);
t.after(() => _resetRouteUpstreamForTest());
const request = install(installRouteMiddleware);
let calls = 0;
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
await gate;
return new Response('offline', { status: 503 });
});
const route = '/api/route';
const query = '?profile=car&coords=-97,30;-97.01,30.01';
const all = [
request(route, `${query}&steps=1`),
request(route, `${query}&steps=1`),
request(route, query),
];
await Promise.resolve();
release();
for (const result of await Promise.all(all)) {
assert.equal(result.statusCode, 502);
assert.equal(result.body.error, 'routing service unavailable');
}
assert.equal(calls, 1);
assert.equal((await request(route, query)).statusCode, 502);
assert.equal(calls, 2, 'the next request asks the routing service again');
});
test('a budget spent in the outbound queue is reported as busy, not as an outage', async (t) => {
_resetRouteUpstreamForTest();
// The second request waits 13 s for its slot, past the 12 s budget.
_setRouteUpstreamIntervalForTest(13_000);
t.after(() => _resetRouteUpstreamForTest());
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() });
const request = install(installRouteMiddleware);
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls += 1;
return Response.json(osrmRouteWithSteps(2));
});
const first = request(
'/api/route',
'?profile=car&coords=-97,30;-97.01,30.01',
);
const second = request(
'/api/route',
'?profile=foot&coords=-97,30;-97.01,30.01',
);
assert.equal((await first).body.ok, true);
t.mock.timers.tick(13_000);
const result = await second;
assert.equal(result.statusCode, 429);
assert.match(result.body.error, /routing busy/);
assert.equal(calls, 1, 'the spent request never reaches the routing service');
});
/** One OSRM route with `count` maneuvers, for the step tests. */
function osrmRouteWithSteps(count) {
return {
+6
View File
@@ -189,7 +189,13 @@ export function createRouteService({
} catch {
/* the status below is authoritative */
}
// Handle 429 and 5xx before route refusals, regardless of the body.
if (response.status === 429) throw httpFailure(response, 'Routing');
if (response.status >= 500)
throw new ToolError(
'unavailable',
`Routing service unavailable (HTTP ${response.status})`,
);
if (payload?.ok === false || response.status === 400)
return { ok: false, error: payload?.error || 'no route found' };
if (!response.ok) throw httpFailure(response, 'Routing');
+86
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { createMcpServer } from '../mcp/index.js';
import { createPlaceSearchService, createRouteService } from '../places.js';
const place = (id, latitude, longitude, extra = {}) => ({
@@ -249,6 +250,91 @@ test('the place and route services read the app routes and translate failures',
);
});
test('a routing outage is reported as unavailable, not as invalid arguments', async () => {
for (const fetchImpl of [
async () =>
Response.json(
{ ok: false, error: 'routing service unavailable' },
{ status: 502 },
),
async () => {
throw new TypeError('fetch failed');
},
]) {
const catalog = composeCatalog({
tools: coreTools,
services: { routing: createRouteService({ fetchImpl }), places },
});
const server = createMcpServer({
catalog,
name: 'test',
version: '1.0.0',
});
const { result } = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'plan_route',
arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } },
},
});
assert.equal(result.isError, true);
assert.equal(result.structuredContent.error, 'unavailable');
assert.doesNotMatch(result.content[0].text, /No walk route/);
}
// Voice receives only the message, so it must name the outage.
const outage = createRouteService({
fetchImpl: async () =>
Response.json(
{ ok: false, error: 'routing service unavailable' },
{ status: 502 },
),
});
await assert.rejects(
outage.route(
[
{ lat: 30, lon: -97 },
{ lat: 30.01, lon: -97.01 },
],
'foot',
),
{ message: 'Routing service unavailable (HTTP 502)' },
);
});
test('an HTML 502 gateway response tells voice the route service is unavailable', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
routing: createRouteService({
fetchImpl: async () =>
new Response('<html>Bad Gateway</html>', {
status: 502,
headers: { 'content-type': 'text/html' },
}),
}),
places,
},
});
const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' });
const { result } = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'plan_route',
arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } },
},
});
assert.equal(result.isError, true);
assert.equal(result.structuredContent.error, 'unavailable');
assert.equal(
result.content[0].text,
'Routing service unavailable (HTTP 502)',
);
});
test('place answers say when the search limits may have left places out', async () => {
const five = Array.from({ length: 5 }, (_, index) =>
place(`p${index}`, 30.27, -97.74),