mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Merge upstream main into denis/rail-layer
This commit is contained in:
commit
9b3ca481ce
11 files changed
+406
-35
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)' });
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user