import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { GBFS_CITY_REGISTRY } from '../../layers/bikeshare/registry.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 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, 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' }, ]; // One minute after v1's fix. const clock = { now: () => Date.UTC(2026, 0, 1, 0, 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, clock }, }); 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', status: 'current', 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', ); }); test('traffic flow aggregates speed, congestion and closures by road length', async () => { // Two 0.01° segments along the equator (about 1.11 km each) and one closure. const segments = [ { coords: [ [0, 0], [0.01, 0], ], trafficLevel: 1, roadCategory: 'motorway', closure: false, }, { coords: [ [0, 0.001], [0.01, 0.001], ], trafficLevel: 0.25, roadCategory: 'street', closure: false, }, { coords: [ [0, 0.002], [0.005, 0.002], ], trafficLevel: 0, roadCategory: 'street', closure: true, }, { coords: [[0, 0]], trafficLevel: 1 }, ]; const zooms = []; const traffic = { getStatus: async () => ({ hasKey: true }), async fetchFlowForBounds(box, { zoom }) { zooms.push(zoom); if (zoom > 10) throw Object.assign(new Error('Zoom in'), { code: 'TILE_VIEW_TOO_WIDE', }); return segments; }, }; const catalog = composeCatalog({ tools: coreTools, services: { traffic } }); const result = await catalog.call('get_traffic_flow', { area: { bbox: [-0.1, -0.1, 0.1, 0.1] }, }); assert.deepEqual(zooms, [12, 11, 10]); assert.equal( result.summary, 'Traffic in the requested box: 50% of free-flow speed on average; 1.7 km congested of 2.8 km measured; 0.6 km closed.', ); assert.equal(result.data.speed_pct_of_free_flow, 50); assert.deepEqual(result.data.by_category, [ { category: 'street', road_km: 1.7, speed_pct_of_free_flow: 17, congested_km: 1.7, }, { category: 'motorway', road_km: 1.1, speed_pct_of_free_flow: 100, congested_km: 0, }, ]); }); test('traffic reports a missing key, empty areas and areas that are too wide', async () => { const keyless = { getStatus: async () => ({ hasKey: false }), fetchFlowForBounds: async () => [], }; await assert.rejects( composeCatalog({ tools: coreTools, services: { traffic: keyless } }).call( 'get_traffic_flow', { area: downtown }, ), (error) => error.code === 'unavailable' && /TomTom key/.test(error.message), ); const quiet = { getStatus: async () => ({ hasKey: true }), fetchFlowForBounds: async () => [], }; assert.equal( ( await composeCatalog({ tools: coreTools, services: { traffic: quiet }, }).call('get_traffic_flow', { area: downtown }) ).summary, 'No live traffic is reported in 2 km around 30.267, -97.743.', ); const wide = { getStatus: async () => ({ hasKey: true }), fetchFlowForBounds: async () => { throw Object.assign(new Error('Zoom in'), { code: 'TILE_VIEW_TOO_WIDE' }); }, }; await assert.rejects( composeCatalog({ tools: coreTools, services: { traffic: wide } }).call( 'get_traffic_flow', { area: { bbox: [-100, 30, -95, 35] } }, ), /too large for traffic detail/, ); }); test('transit marks stale feeds and drops expired positions', async () => { const headers = new Headers({ 'x-gev-cache': 'STALE-ERROR' }); const snapshot = (extra) => ({ ok: true, status: 200, headers, json: async () => ({ vehicles: [ vehicles[0], { ...vehicles[0], id: 'old', timestamp: 1767225600 - 3600 }, ], ...extra, }), }); const result = await composeCatalog({ tools: coreTools, services: { transit: transit(snapshot()), clock }, }).call('get_transit_vehicles', { area: downtown }); assert.deepEqual( result.data.rows.map((row) => row.id), ['v1'], ); assert.equal(result.data.expired_positions_dropped, 1); assert.equal(result.data.stale, true); assert.equal(result.data.feeds[0].status, 'stale'); assert.equal( result.summary, '1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).', ); }); test('bike share names the systems it did not search', async () => { const empty = { data: { stations: [] } }; const catalog = composeCatalog({ tools: coreTools, services: { bikeshare: { systems: GBFS_CITY_REGISTRY, getStations: async () => empty, }, }, }); // New York, Philadelphia, Washington and Boston. const result = await catalog.call('get_bike_share', { area: { bbox: [-77.5, 38.5, -70.5, 42.8] }, }); assert.equal(result.data.systems.length, 3); assert.ok(result.data.systems_not_searched.length >= 1); assert.match( result.summary, /more not searched: .*; use a smaller area\)\.$/, ); }); test('traffic counts only roads inside a radius area and reports missing tiles', async () => { const flow = (segments, partial = false) => ({ getStatus: async () => ({ hasKey: true }), fetchFlowDetail: async () => ({ segments, partial }), }); const call = (traffic, area) => composeCatalog({ tools: coreTools, services: { traffic } }).call( 'get_traffic_flow', { area }, ); const circle = { lat: 0, lon: 0, radius_km: 10 }; // In the circle's bounding box, but about 12.6 km from its center. const corner = [ { coords: [ [0.08, 0.08], [0.085, 0.085], ], closure: true, trafficLevel: 1, }, ]; const outside = await call(flow(corner), circle); assert.equal(outside.data.closed_km, 0); assert.equal(outside.data.measured_km, 0); // A 20 km road through the center keeps the 20 km inside the circle. const across = [ { coords: [ [-0.2, 0], [0.2, 0], ], trafficLevel: 1, }, ]; const crossing = await call(flow(across), circle); assert.ok(Math.abs(crossing.data.measured_km - 20) < 0.2); // A road cutting the circle with both ends outside it. const chord = [ { coords: [ [-0.2, 0.05], [0.2, 0.05], ], trafficLevel: 1, }, ]; assert.ok((await call(flow(chord), circle)).data.measured_km > 10); const partial = await call(flow(across, true), circle); assert.equal(partial.data.partial, true); assert.match(partial.summary, /Some map tiles did not load/); }); test('a whole-world bike-share search names every system it skipped', async () => { const empty = { data: { stations: [] } }; const result = await composeCatalog({ tools: coreTools, services: { bikeshare: { systems: GBFS_CITY_REGISTRY, getStations: async () => empty, }, }, }).call('get_bike_share', { area: { bbox: [-180, -90, 180, 90] } }); const named = [...result.data.systems, ...result.data.systems_not_searched]; assert.equal(named.length, GBFS_CITY_REGISTRY.length); });