mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat: add bike-share and transit tools
Add get_bike_share over the public GBFS systems and get_transit_vehicles over the GTFS-Realtime feeds. Each finds the systems or feeds whose coverage reaches the area, reads up to three, and reports any that were unavailable instead of failing the whole answer. Transit results carry each feed's attribution and license.
This commit is contained in:
1 parent
2ba751ace8
commit
8c97fed92d
10 files changed
+510
-4
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
||||
serves earthquake, active-fire, launch, aircraft, satellite, camera, radio,
|
||||
place and routing queries over stdio to clients such as Claude Code, reading
|
||||
place, routing, bike-share and transit queries over stdio to clients such as Claude Code, reading
|
||||
from a running app. Tools are defined
|
||||
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
||||
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
||||
|
||||
@@ -9,7 +9,8 @@ reading from a running app's `/api` routes (default `http://localhost:4173`).
|
||||
Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
||||
area, by identifier, tracks, and type and route lookups), satellites (next
|
||||
pass over a point, and those overhead now), public cameras (including a
|
||||
camera's current image), radio stations, place search and routing. Tools reuse the layers' portable source factories, take a
|
||||
camera's current image), radio stations, place search, routing, bike-share
|
||||
stations and transit vehicles. Tools reuse the layers' portable source factories, take a
|
||||
shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. The app does not import the tools, so its runtime
|
||||
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
|
||||
|
||||
+4
-1
@@ -49,7 +49,8 @@ Services are the portable source factories the layers already use, such as
|
||||
plus a `places` service with `resolve(name, { signal })`. Sources request
|
||||
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
||||
code runs wherever an application routes those paths.
|
||||
`createGeocodePlaceService` resolves place names through `/api/geocode`;
|
||||
The `bikeshare` service is `{ systems, getStations }`: the system registry and
|
||||
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
|
||||
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
|
||||
is configured; `createRouteService` plans routes through `/api/route`.
|
||||
|
||||
@@ -103,3 +104,5 @@ public feeds the sources already use.
|
||||
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
|
||||
| `places_nearby` | `placeSearch` | Notable places around a point (Google Places) |
|
||||
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
|
||||
| `get_bike_share` | `bikeshare` | Live GBFS stations in an area, with bikes and docks available |
|
||||
| `get_transit_vehicles` | `transit` | Live GTFS-Realtime vehicle positions in an area, optionally one route |
|
||||
@@ -874,5 +874,6 @@
|
||||
"src/tools/queries/space.test.mjs",
|
||||
"src/tools/queries/media.test.mjs",
|
||||
"src/tools/queries/places.test.mjs",
|
||||
"src/layers/transit/source.test.mjs"
|
||||
"src/layers/transit/source.test.mjs",
|
||||
"src/tools/queries/mobility.test.mjs"
|
||||
]
|
||||
@@ -3093,6 +3093,8 @@
|
||||
"src/tools/queries/aviation.js",
|
||||
"src/tools/queries/media.js",
|
||||
"src/tools/queries/places.js",
|
||||
"src/tools/queries/mobility.js",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/sources/tle.js",
|
||||
"src/data/satellitePass.js"
|
||||
],
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
* at a running God's Eye View server's `/api` routes.
|
||||
*/
|
||||
|
||||
import { GBFS_CITY_REGISTRY } from '../../src/layers/bikeshare/registry.js';
|
||||
import { createBikeshareSource } from '../../src/layers/bikeshare/source.js';
|
||||
import { createCctvSource } from '../../src/layers/cctv/source.js';
|
||||
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
|
||||
import { createFirmsSource } from '../../src/layers/firms/source.js';
|
||||
import { createLaunchSource } from '../../src/layers/launches/source.js';
|
||||
import { createRadioSource } from '../../src/layers/radio/source.js';
|
||||
import { createSatelliteSource } from '../../src/layers/satellites/source.js';
|
||||
import { createTransitSource } from '../../src/layers/transit/source.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
createOpenSkySource,
|
||||
@@ -52,6 +55,11 @@ export function createLocalToolServices(options = {}) {
|
||||
radio: createRadioSource({ fetchImpl }),
|
||||
placeSearch: createPlaceSearchService({ fetchImpl }),
|
||||
routing: createRouteService({ fetchImpl }),
|
||||
bikeshare: {
|
||||
systems: GBFS_CITY_REGISTRY,
|
||||
getStations: createBikeshareSource({ fetchImpl }).getStations,
|
||||
},
|
||||
transit: createTransitSource({ fetchImpl }),
|
||||
places: createGeocodePlaceService({ fetchImpl }),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getAircraftTrack,
|
||||
} from './queries/aviation.js';
|
||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||
import { getBikeShare, getTransitVehicles } from './queries/mobility.js';
|
||||
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||
import {
|
||||
findCctvCameras,
|
||||
@@ -60,4 +61,6 @@ export const coreTools = Object.freeze([
|
||||
searchPlaces,
|
||||
placesNearby,
|
||||
planRoute,
|
||||
getBikeShare,
|
||||
getTransitVehicles,
|
||||
]);
|
||||
@@ -84,6 +84,8 @@ test('the stdio server answers newline-delimited requests using only its data so
|
||||
'search_places',
|
||||
'places_nearby',
|
||||
'plan_route',
|
||||
'get_bike_share',
|
||||
'get_transit_vehicles',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/** Bike-share and public transit queries. */
|
||||
|
||||
import {
|
||||
parseStationInformation,
|
||||
parseStationStatus,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
areaRadiusKm,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
const MAX_SYSTEMS = 3;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** Systems or feeds whose coverage reaches the area, nearest first. */
|
||||
function covering(area, entries, centerOf, radiusOf) {
|
||||
const center = areaCenter(area);
|
||||
const reach = areaRadiusKm(area);
|
||||
return entries
|
||||
.map((entry) => ({ entry, distance: distanceKm(center, centerOf(entry)) }))
|
||||
.filter(({ entry, distance }) => distance <= reach + radiusOf(entry))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, MAX_SYSTEMS)
|
||||
.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
export const getBikeShare = defineTool({
|
||||
name: 'get_bike_share',
|
||||
title: 'Bike-share stations',
|
||||
description:
|
||||
'Live bike-share stations in an area from public GBFS feeds, nearest the ' +
|
||||
'center first, with bikes and docks available.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['bikeshare'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const { bikeshare } = services;
|
||||
const systems = covering(
|
||||
area,
|
||||
bikeshare.systems,
|
||||
(system) => ({ lat: system.centerLat, lon: system.centerLon }),
|
||||
(system) => system.loadRadiusKm,
|
||||
);
|
||||
if (!systems.length)
|
||||
return {
|
||||
summary: `No supported bike-share system covers ${area.label}.`,
|
||||
data: { ...capRows([], args.limit), systems: [] },
|
||||
};
|
||||
const center = areaCenter(area);
|
||||
const results = await Promise.allSettled(
|
||||
systems.map(async (system) => {
|
||||
const [information, status] = await Promise.all([
|
||||
bikeshare.getStations(system.stationInformationUrl, { signal }),
|
||||
bikeshare.getStations(system.stationStatusUrl, { signal }),
|
||||
]);
|
||||
const live = parseStationStatus(status);
|
||||
return [...parseStationInformation(information).values()].map(
|
||||
(station) => ({ system, station, live: live.get(station.stationId) }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (results.every((result) => result.status === 'rejected'))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Bike-share feeds are unavailable right now',
|
||||
);
|
||||
const rows = results
|
||||
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
||||
.filter(({ station }) => areaContains(area, station))
|
||||
.map(({ system, station, live }) => ({
|
||||
system: system.city,
|
||||
provider: system.provider ?? null,
|
||||
station_id: station.stationId,
|
||||
name: station.name || null,
|
||||
lat: round(station.lat, 6),
|
||||
lon: round(station.lon, 6),
|
||||
bikes_available: live?.bikesAvailable ?? null,
|
||||
docks_available: live?.docksAvailable ?? null,
|
||||
capacity: station.capacity,
|
||||
renting: (live?.isRenting ?? station.isRenting) === true,
|
||||
returning: (live?.isReturning ?? station.isReturning) === true,
|
||||
last_reported: Number.isFinite(live?.lastReported)
|
||||
? isoTime(live.lastReported * 1000)
|
||||
: null,
|
||||
distance_km: round(distanceKm(center, station), 2),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const bikes = rows.reduce(
|
||||
(sum, row) => sum + (row.bikes_available ?? 0),
|
||||
0,
|
||||
);
|
||||
const failed = results.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length;
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'bike-share station')} in ${area.label} with ` +
|
||||
`${countNoun(bikes, 'bike')} available` +
|
||||
(failed ? ` (${countNoun(failed, 'system')} unavailable).` : '.'),
|
||||
data: {
|
||||
...capRows(rows, args.limit),
|
||||
systems: systems.map((system) => system.city),
|
||||
bikes_available: bikes,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getTransitVehicles = defineTool({
|
||||
name: 'get_transit_vehicles',
|
||||
title: 'Transit vehicles',
|
||||
description:
|
||||
'Live positions of buses, trains and other transit vehicles in an area ' +
|
||||
'from public GTFS-Realtime feeds, optionally for one route.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
route: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
description:
|
||||
'Route id or vehicle label to match, such as "1" or "Red".',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['transit'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const feeds = covering(
|
||||
area,
|
||||
await services.transit.getFeeds({ signal }),
|
||||
(feed) => feed.center,
|
||||
(feed) => feed.loadRadiusKm ?? 0,
|
||||
);
|
||||
if (!feeds.length)
|
||||
return {
|
||||
summary: `No supported transit feed covers ${area.label}.`,
|
||||
data: { ...capRows([], args.limit), feeds: [] },
|
||||
};
|
||||
const center = areaCenter(area);
|
||||
const results = await Promise.allSettled(
|
||||
feeds.map(async (feed) => {
|
||||
const response = await services.transit.requestSnapshot(feed.id, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Transit HTTP ${response.status}`);
|
||||
const snapshot = await response.json();
|
||||
return (snapshot.vehicles || []).map((vehicle) => ({ feed, vehicle }));
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (results.every((result) => result.status === 'rejected'))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Transit feeds are unavailable right now',
|
||||
);
|
||||
const wanted = args.route?.trim().toLowerCase();
|
||||
const rows = results
|
||||
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
||||
.filter(({ vehicle }) => areaContains(area, vehicle))
|
||||
.filter(
|
||||
({ vehicle }) =>
|
||||
!wanted ||
|
||||
[vehicle.routeId, vehicle.label].some(
|
||||
(value) => String(value || '').toLowerCase() === wanted,
|
||||
),
|
||||
)
|
||||
.map(({ feed, vehicle }) => ({
|
||||
feed: feed.name,
|
||||
id: vehicle.id,
|
||||
label: vehicle.label ?? null,
|
||||
route_id: vehicle.routeId ?? null,
|
||||
trip_id: vehicle.tripId ?? null,
|
||||
lat: vehicle.lat,
|
||||
lon: vehicle.lon,
|
||||
bearing_deg: round(vehicle.bearing, 0),
|
||||
speed_mps: round(vehicle.speedMps, 1),
|
||||
status: vehicle.status ?? null,
|
||||
occupancy: vehicle.occupancy ?? null,
|
||||
updated: Number.isFinite(vehicle.timestamp)
|
||||
? isoTime(vehicle.timestamp * 1000)
|
||||
: null,
|
||||
distance_km: round(distanceKm(center, vehicle), 2),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const failed = results.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length;
|
||||
const what = args.route ? ` on route ${args.route}` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` +
|
||||
(failed ? ` (${countNoun(failed, 'feed')} unavailable).` : '.'),
|
||||
data: {
|
||||
...capRows(rows, args.limit),
|
||||
feeds: feeds.map((feed) => ({
|
||||
name: feed.name,
|
||||
operator: feed.operator ?? null,
|
||||
attribution: feed.attribution ?? null,
|
||||
license: feed.license ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
const system = (id, city, centerLat, centerLon) => ({
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
loadRadiusKm: 30,
|
||||
provider: 'Test Bikes',
|
||||
stationInformationUrl: `https://gbfs.example/${id}/station_information.json`,
|
||||
stationStatusUrl: `https://gbfs.example/${id}/station_status.json`,
|
||||
});
|
||||
const documents = {
|
||||
'https://gbfs.example/austin/station_information.json': {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 's1',
|
||||
name: 'Congress & 2nd',
|
||||
lat: 30.2645,
|
||||
lon: -97.7445,
|
||||
capacity: 12,
|
||||
},
|
||||
{
|
||||
station_id: 's2',
|
||||
name: 'Far Station',
|
||||
lat: 30.4,
|
||||
lon: -97.9,
|
||||
capacity: 8,
|
||||
},
|
||||
{
|
||||
station_id: 's3',
|
||||
name: 'Lamar & 5th',
|
||||
lat: 30.268,
|
||||
lon: -97.753,
|
||||
capacity: '10',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'https://gbfs.example/austin/station_status.json': {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 's1',
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 8,
|
||||
is_renting: 1,
|
||||
last_reported: 1767225600,
|
||||
},
|
||||
{
|
||||
station_id: 's3',
|
||||
num_bikes_available: 0,
|
||||
num_docks_available: 10,
|
||||
is_renting: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const bikeshare = (systems, failing = new Set()) => ({
|
||||
systems,
|
||||
async getStations(url) {
|
||||
if ([...failing].some((id) => url.includes(`/${id}/`)))
|
||||
throw new Error('GBFS HTTP 502');
|
||||
return documents[url];
|
||||
},
|
||||
});
|
||||
const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 2 };
|
||||
|
||||
test('bike share joins station information with live status inside the area', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: bikeshare([
|
||||
system('austin', 'Austin, TX', 30.27, -97.74),
|
||||
system('oslo', 'Oslo', 59.9, 10.7),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', { area: downtown });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 bike-share stations in 2 km around 30.267, -97.743 with 4 bikes available.',
|
||||
);
|
||||
assert.deepEqual(result.data.systems, ['Austin, TX']);
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
system: 'Austin, TX',
|
||||
provider: 'Test Bikes',
|
||||
station_id: 's1',
|
||||
name: 'Congress & 2nd',
|
||||
lat: 30.2645,
|
||||
lon: -97.7445,
|
||||
bikes_available: 4,
|
||||
docks_available: 8,
|
||||
capacity: 12,
|
||||
renting: true,
|
||||
returning: true,
|
||||
last_reported: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 0.33,
|
||||
});
|
||||
assert.equal(result.data.rows[1].renting, false);
|
||||
assert.equal(result.data.rows[1].capacity, 10);
|
||||
});
|
||||
|
||||
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare([system('oslo', 'Oslo', 59.9, 10.7)]) },
|
||||
});
|
||||
const empty = await none.call('get_bike_share', { area: downtown });
|
||||
assert.equal(
|
||||
empty.summary,
|
||||
'No supported bike-share system covers 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.equal(empty.data.total, 0);
|
||||
const both = [
|
||||
system('austin', 'Austin, TX', 30.27, -97.74),
|
||||
system('round-rock', 'Round Rock', 30.5, -97.68),
|
||||
];
|
||||
const partial = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare(both, new Set(['round-rock'])) },
|
||||
});
|
||||
assert.match(
|
||||
(
|
||||
await partial.call('get_bike_share', {
|
||||
area: { lat: 30.4, lon: -97.7, radius_km: 20 },
|
||||
})
|
||||
).summary,
|
||||
/\(1 system unavailable\)\.$/,
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare(both, new Set(['austin', 'round-rock'])) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_bike_share', { area: downtown }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
const feed = {
|
||||
id: 'capmetro',
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metro',
|
||||
center: { lat: 30.27, lon: -97.74 },
|
||||
loadRadiusKm: 50,
|
||||
attribution: 'Capital Metro',
|
||||
license: 'Open data',
|
||||
};
|
||||
const vehicles = [
|
||||
{
|
||||
id: 'v1',
|
||||
lat: 30.2675,
|
||||
lon: -97.7425,
|
||||
bearing: 91.6,
|
||||
speedMps: 8.23,
|
||||
timestamp: 1767225600,
|
||||
routeId: '1',
|
||||
tripId: 't1',
|
||||
label: '1 North',
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
lat: 30.268,
|
||||
lon: -97.745,
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: null,
|
||||
routeId: '801',
|
||||
label: 'Rapid',
|
||||
},
|
||||
{ id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' },
|
||||
];
|
||||
const transit = (
|
||||
snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) },
|
||||
) => ({
|
||||
requested: [],
|
||||
async getFeeds() {
|
||||
return [
|
||||
feed,
|
||||
{ ...feed, id: 'far', name: 'Far Away', center: { lat: 0, lon: 0 } },
|
||||
];
|
||||
},
|
||||
async requestSnapshot(id) {
|
||||
this.requested.push(id);
|
||||
return snapshot;
|
||||
},
|
||||
});
|
||||
|
||||
test('transit vehicles come from nearby feeds and can be limited to a route', async () => {
|
||||
const source = transit();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: source },
|
||||
});
|
||||
const all = await catalog.call('get_transit_vehicles', { area: downtown });
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'2 transit vehicles in 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.deepEqual(source.requested, ['capmetro']);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
feed: 'CapMetro',
|
||||
id: 'v1',
|
||||
label: '1 North',
|
||||
route_id: '1',
|
||||
trip_id: 't1',
|
||||
lat: 30.2675,
|
||||
lon: -97.7425,
|
||||
bearing_deg: 92,
|
||||
speed_mps: 8.2,
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
updated: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 0.07,
|
||||
});
|
||||
assert.deepEqual(all.data.feeds, [
|
||||
{
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metro',
|
||||
attribution: 'Capital Metro',
|
||||
license: 'Open data',
|
||||
},
|
||||
]);
|
||||
const rapid = await catalog.call('get_transit_vehicles', {
|
||||
area: downtown,
|
||||
route: 'rapid',
|
||||
});
|
||||
assert.equal(
|
||||
rapid.summary,
|
||||
'1 transit vehicle on route rapid in 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.equal(rapid.data.rows[0].id, 'v2');
|
||||
});
|
||||
|
||||
test('transit reports uncovered areas and unavailable feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: transit() },
|
||||
});
|
||||
const oslo = await catalog.call('get_transit_vehicles', {
|
||||
area: { lat: 59.9, lon: 10.7, radius_km: 5 },
|
||||
});
|
||||
assert.equal(
|
||||
oslo.summary,
|
||||
'No supported transit feed covers 5 km around 59.900, 10.700.',
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: transit({ ok: false, status: 503 }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_transit_vehicles', { area: downtown }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user