Merge branch 'main' into feat/bikeshare-norway

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-15 17:32:25 +01:00
commit 42d3c0c0d2
260 files changed
+28882 -1788

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { composeCatalog, defineTool } from './catalog.js';
for (const intercept of [false, true]) {
test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => {
let calls = 0;
const controller = new AbortController();
const query = defineTool({
name: 'fixture_query',
kind: 'query',
title: 'Fixture',
description: 'Offline fixture',
inputSchema: { type: 'object' },
run: async () => {
calls++;
return { summary: 'fixture', data: {} };
},
});
const catalog = composeCatalog({
tools: [query],
interceptors: intercept
? [
async (call, next) => {
await Promise.resolve();
controller.abort();
return next(call);
},
]
: [],
});
if (!intercept) controller.abort();
await assert.rejects(
catalog.call('fixture_query', {}, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(calls, 0);
const fresh = composeCatalog({ tools: [query] });
assert.equal(
(
await fresh.call(
'fixture_query',
{},
{ signal: new AbortController().signal },
)
).summary,
'fixture',
);
assert.equal(calls, 1);
});
}
+1
View File
@@ -178,6 +178,7 @@ export function composeCatalog({
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
};
const execute = async ({ tool, args, signal }) => {
signal?.throwIfAborted();
const problems = validateValue(tool.inputSchema, args);
if (problems.length)
throw new ToolError('invalid_arguments', problems.join('; '));
+11 -1
View File
@@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
* coverage tiles come through the app's server). Everything from the app's
* coverage tiles come through the app's server), Google Street View and
* KartaView. Everything from the app's
* own server comes through the MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
@@ -37,8 +38,17 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Google Street View's tiles, controls and contributed photos (the Maps
// JavaScript API itself comes from maps.googleapis.com above).
'https://streetviewpixels-pa.googleapis.com',
'https://maps.gstatic.com',
'https://*.ggpht.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
// KartaView's API, its image proxy and its photo storage (storage1…storage13).
'https://api.kartaview.org',
'https://cdn.kartaview.org',
'https://*.openstreetcam.org',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
+17
View File
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// The API script and lookups, panorama tiles, control images, contributed photos.
for (const url of [
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
'https://lh3.ggpht.com/p/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
});
+28
View File
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
assert.equal(result.data.rows[1].capacity, 10);
});
test('bike share returns localized names without losing stations with malformed names', async () => {
const informationUrl = 'https://gbfs.example/austin/station_information.json';
const information = structuredClone(documents[informationUrl]);
information.version = '3.0';
information.data.stations[0].name = [
{ language: 'hu', text: 'Magyar név' },
{ language: 'en', text: 'Congress & 2nd' },
];
information.data.stations[2].name = [null, { language: 42, text: [] }];
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
const catalog = composeCatalog({
tools: coreTools,
services: {
bikeshare: {
...source,
getStations: (url) =>
url === informationUrl ? information : source.getStations(url),
},
},
});
const result = await catalog.call('get_bike_share', { area: downtown });
assert.equal(result.data.total, 2);
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
assert.equal(result.data.rows[0].bikes_available, 4);
assert.equal(result.data.rows[1].name, null);
assert.doesNotMatch(result.summary, /unavailable/);
});
test('bike share reports uncovered areas and failed systems honestly', async () => {
const none = composeCatalog({
tools: coreTools,
+21
View File
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
assert.equal(tleCatalogNumber('1 xx'), null);
});
for (const missingLine of [1, 2]) {
test(`TLE parsing resumes after a set missing line ${missingLine}`, () => {
const sets = Array.from({ length: 8 }, (_, i) => {
const lines = TLE.trim().split('\n').slice(0, 3);
lines[0] = `SAT ${i}`;
return lines;
});
sets[1].splice(missingLine, 1);
const entries = parseTleText(sets.flat().join('\r\n'));
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'],
);
assert.ok(
entries.every(
(entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '),
),
);
});
}
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
const catalog = composeCatalog({
tools: coreTools,
+3 -1
View File
@@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
/** Format an epoch millisecond time as ISO 8601, or null. */
export function isoTime(ms) {
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
if (!Number.isFinite(ms)) return null;
const date = new Date(ms);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
/** "1 earthquake", "3 earthquakes". */
+14 -4
View File
@@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') {
fail('must be a string');
break;
}
if (schema.minLength != null && value.length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && value.length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
if (schema.minLength != null || schema.maxLength != null) {
// Code points, as JSON Schema counts. Counting stops once both bounds
// are decided, so a huge argument costs no more than its limit.
const limit = Math.max(
schema.minLength ?? 0,
schema.maxLength != null ? schema.maxLength + 1 : 0,
);
let length = 0;
for (const _ of value) if (++length >= limit) break;
if (schema.minLength != null && length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
}
if (
schema.pattern !== undefined &&
!new RegExp(schema.pattern, 'u').test(value)
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateValue } from './schema.js';
test('tool string schemas count Unicode characters rather than UTF-16 units', () => {
const value = '𠮷野家😀';
assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []);
assert.equal(
validateValue({ type: 'string', minLength: 5 }, value).length,
1,
);
assert.equal(
validateValue({ type: 'string', maxLength: 3 }, value).length,
1,
);
assert.deepEqual(
validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'),
[],
);
assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1);
});
test('tool string bounds hold at their edges and on huge arguments', () => {
const string = (bounds) => ({ type: 'string', ...bounds });
assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []);
assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1);
assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []);
assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []);
// Counting all of it takes roughly 0.7 s; stopping at the limit takes none.
const huge = 'x'.repeat(1e8);
const started = performance.now();
assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1);
assert.ok(performance.now() - started < 200, 'stops counting past the limit');
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isoTime } from './results.js';
import { getBikeShare } from './queries/mobility.js';
test('out-of-range finite timestamps are unknown rather than throwing', async () => {
for (const value of [1e30, -1e30, NaN, Infinity, null])
assert.equal(isoTime(value), null);
assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z');
assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z');
assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z');
for (const value of [8640000000000001, -8640000000000001, -Infinity])
assert.equal(isoTime(value), null);
assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z');
const station = {
station_id: 's1',
name: 'Fixture',
lat: 0,
lon: 0,
capacity: 4,
};
const result = await getBikeShare.run(
{ area: { bbox: [-1, -1, 1, 1] } },
{
services: {
bikeshare: {
systems: [
{
id: 'fixture',
city: 'Fixture',
centerLat: 0,
centerLon: 0,
loadRadiusKm: 30,
stationInformationUrl: 'information',
stationStatusUrl: 'status',
},
],
async getStations(url) {
return {
data: {
stations:
url === 'information'
? [station]
: [
{
station_id: 's1',
last_reported: 1e30,
num_bikes_available: 2,
},
],
},
};
},
},
},
},
);
assert.equal(result.data.returned, 1);
assert.equal(result.data.rows[0].last_reported, null);
});