mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
527 lines
15 KiB
JavaScript
527 lines
15 KiB
JavaScript
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);
|
|
});
|