UNPKG

minotor

Version:

A lightweight client-side transit routing library.

775 lines (705 loc) 21.6 kB
import assert from 'node:assert'; import { describe, it } from 'node:test'; import { Timetable } from '../../router.js'; import { Stop } from '../../stops/stops.js'; import { StopsIndex } from '../../stops/stopsIndex.js'; import { Route } from '../../timetable/route.js'; import { timeFromHMS, timeFromString } from '../../timetable/time.js'; import { RouteTypes, ServiceRoute, StopAdjacency, TransferTypes, } from '../../timetable/timetable.js'; import { Query } from '../query.js'; import { Result } from '../result.js'; import { RoutingState, TransferEdge, VehicleEdge } from '../router.js'; const NB_STOPS = 7; describe('Result', () => { const stop1: Stop = { id: 0, sourceStopId: 'stop1', name: 'Lausanne', lat: 0, lon: 0, children: [], parent: undefined, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const stop2: Stop = { id: 1, sourceStopId: 'stop2', name: 'Fribourg', lat: 0, lon: 0, children: [], parent: undefined, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const stop3: Stop = { id: 2, sourceStopId: 'stop3', name: 'Bern', lat: 0, lon: 0, children: [], parent: undefined, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const stop4: Stop = { id: 3, sourceStopId: 'stop4', name: 'Olten', lat: 0, lon: 0, children: [], parent: undefined, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const parentStop: Stop = { id: 4, sourceStopId: 'parent', name: 'Basel', lat: 0, lon: 0, children: [5, 6], parent: undefined, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const childStop1: Stop = { id: 5, sourceStopId: 'child1', name: 'Basel Pl. 1', lat: 0, lon: 0, children: [], parent: 4, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const childStop2: Stop = { id: 6, sourceStopId: 'child2', name: 'Basel Pl. 2', lat: 0, lon: 0, children: [], parent: 4, locationType: 'SIMPLE_STOP_OR_PLATFORM', }; const stopsAdjacency: StopAdjacency[] = [ { routes: [0] }, { routes: [0] }, { routes: [0, 1] }, { routes: [1] }, { routes: [1] }, { routes: [1] }, { routes: [1] }, ]; const routesAdjacency = [ Route.of({ id: 0, serviceRouteId: 0, trips: [ { stops: [ { id: 0, arrivalTime: timeFromString('08:00:00'), departureTime: timeFromString('08:05:00'), }, { id: 1, arrivalTime: timeFromString('08:30:00'), departureTime: timeFromString('08:35:00'), }, { id: 2, arrivalTime: timeFromString('09:00:00'), departureTime: timeFromString('09:05:00'), }, ], }, ], }), Route.of({ id: 1, serviceRouteId: 1, trips: [ { stops: [ { id: 2, arrivalTime: timeFromString('09:10:00'), departureTime: timeFromString('09:15:00'), }, { id: 3, arrivalTime: timeFromString('09:45:00'), departureTime: timeFromString('09:50:00'), }, { id: 5, arrivalTime: timeFromString('10:10:00'), departureTime: timeFromString('10:15:00'), }, ], }, ], }), ]; const routes: ServiceRoute[] = [ { type: RouteTypes.RAIL, name: 'Line 1', routes: [0] }, { type: RouteTypes.RAIL, name: 'Line 2', routes: [1] }, ]; const mockStopsIndex = new StopsIndex([ stop1, stop2, stop3, stop4, parentStop, childStop1, childStop2, ]); const mockTimetable = new Timetable(stopsAdjacency, routesAdjacency, routes); const mockQuery = new Query.Builder() .from(0) .to(new Set([2, 3])) .departureTime(timeFromHMS(8, 0, 0)) .build(); describe('bestRoute', () => { it('should return undefined when no route exists', () => { const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2, 3] }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(); assert.strictEqual(route, undefined); }); it('should return undefined for unreachable destination', () => { const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2, 3], arrivals: [[1, timeFromHMS(8, 30, 0), 0]], graph: [[[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]]], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(3); // stop 3 not in arrivals assert.strictEqual(route, undefined); }); it('should return route to closest destination when multiple destinations exist', () => { const vehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2, 3], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [2, timeFromHMS(9, 0, 0), 1], [3, timeFromHMS(9, 30, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, vehicleEdge]], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(); assert(route); assert.strictEqual(route.legs.length, 1); const firstLeg = route.legs[0]; assert(firstLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 2); }); it('should return route to fastest child stop when parent stop is queried', () => { const vehicleEdge: VehicleEdge = { arrival: timeFromHMS(10, 10, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 1, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [2], destinations: [4], arrivals: [ [2, timeFromHMS(9, 10, 0), 1], [5, timeFromHMS(10, 10, 0), 2], [6, timeFromHMS(10, 30, 0), 2], ], graph: [ [[2, { stopId: 2, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [ [ 2, { arrival: timeFromHMS(9, 10, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }, ], ], // round 1 [[5, vehicleEdge]], // round 2 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(4); assert(route); assert.strictEqual(route.legs.length, 2); const lastLeg = route.legs[route.legs.length - 1]; assert(lastLeg); assert.strictEqual(lastLeg.to.id, 5); // faster child }); it('should handle simple single-leg route reconstruction', () => { const vehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [2, timeFromHMS(9, 0, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, vehicleEdge]], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(2); assert(route); assert.strictEqual(route.legs.length, 1); const firstLeg = route.legs[0]; assert(firstLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 2); }); it('should handle multi-leg route with transfer', () => { const firstVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const secondVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 45, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 1, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [3], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [2, timeFromHMS(9, 0, 0), 1], [3, timeFromHMS(9, 45, 0), 2], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, firstVehicleEdge]], // round 1 [[3, secondVehicleEdge]], // round 2 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(3); assert(route); assert.strictEqual(route.legs.length, 2); const firstLeg = route.legs[0]; const secondLeg = route.legs[1]; assert(firstLeg); assert(secondLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 2); assert.strictEqual(secondLeg.from.id, 2); assert.strictEqual(secondLeg.to.id, 3); }); }); describe('bestRouteToStopId', () => { it('should return route when given a single SourceStopId', () => { const vehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [2, timeFromHMS(9, 0, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, vehicleEdge]], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRouteToSourceStopId('stop3'); assert(route); assert.strictEqual(route.legs.length, 1); const firstLeg = route.legs[0]; assert(firstLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 2); }); it('should return route to closest destination when given a Set of SourceStopIds', () => { const vehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2, 3], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [2, timeFromHMS(9, 0, 0), 1], [3, timeFromHMS(9, 45, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, vehicleEdge]], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRouteToSourceStopId(new Set(['stop3', 'stop4'])); assert(route); assert.strictEqual(route.legs.length, 1); const firstLeg = route.legs[0]; assert(firstLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 2); // stop 2 arrives faster }); }); describe('continuous trips', () => { it('should handle single continuous trip correctly', () => { const firstVehicleEdge: VehicleEdge = { arrival: timeFromHMS(8, 30, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 0, tripIndex: 0, }; const continuousVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 1, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, continuationOf: firstVehicleEdge, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [1, timeFromHMS(8, 30, 0), 1], [2, timeFromHMS(9, 0, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [ [1, firstVehicleEdge], [2, continuousVehicleEdge], ], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(2); assert(route); assert.strictEqual(route.legs.length, 1); const leg = route.legs[0]; assert(leg); assert.strictEqual(leg.from.id, 0); assert.strictEqual(leg.to.id, 2); }); it('should handle continuous trips with route change mid-journey', () => { const firstVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 0, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const continuousVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 45, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 1, tripIndex: 0, continuationOf: firstVehicleEdge, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [3], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [3, timeFromHMS(9, 45, 0), 1], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[3, continuousVehicleEdge]], // round 1 ], }), mockStopsIndex, mockTimetable, ); const route = result.bestRoute(3); assert(route); assert.strictEqual(route.legs.length, 1); const leg = route.legs[0]; assert(leg); assert.strictEqual(leg.from.id, 0); assert.strictEqual(leg.to.id, 3); }); it('should handle route reconstruction with actual transfer edges', () => { const firstVehicleEdge: VehicleEdge = { arrival: timeFromHMS(8, 30, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 0, tripIndex: 0, }; const transferEdge: TransferEdge = { arrival: timeFromHMS(8, 35, 0), from: 1, to: 2, type: TransferTypes.RECOMMENDED, minTransferTime: 5, }; const secondVehicleEdge: VehicleEdge = { arrival: timeFromHMS(9, 15, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 1, tripIndex: 0, }; const state = RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [3], arrivals: [ [0, timeFromHMS(8, 0, 0), 0], [1, timeFromHMS(8, 30, 0), 1], [2, timeFromHMS(8, 35, 0), 1], [3, timeFromHMS(9, 15, 0), 2], ], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [ [1, firstVehicleEdge], [2, transferEdge], ], // round 1 [[3, secondVehicleEdge]], // round 2 ], }); const result = new Result( mockQuery.to, state, mockStopsIndex, mockTimetable, ); const route = result.bestRoute(3); assert(route); assert.strictEqual( route.legs.length, 3, 'Route should have vehicle + transfer + vehicle legs', ); const firstLeg = route.legs[0]; const transferLeg = route.legs[1]; const thirdLeg = route.legs[2]; assert(firstLeg); assert(transferLeg); assert(thirdLeg); assert.strictEqual(firstLeg.from.id, 0); assert.strictEqual(firstLeg.to.id, 1); assert('departureTime' in firstLeg); assert('route' in firstLeg); assert.strictEqual(transferLeg.from.id, 1); assert.strictEqual(transferLeg.to.id, 2); assert('type' in transferLeg); assert('minTransferTime' in transferLeg); assert.strictEqual(transferLeg.type, 'RECOMMENDED'); assert.strictEqual(thirdLeg.from.id, 2); assert.strictEqual(thirdLeg.to.id, 3); assert('departureTime' in thirdLeg); assert('route' in thirdLeg); // Verify the routing state recorded the correct leg numbers. assert.strictEqual(state.getArrival(1)?.legNumber, 1); assert.strictEqual(state.getArrival(2)?.legNumber, 1); assert.strictEqual(state.getArrival(3)?.legNumber, 2); }); }); describe('arrivalAt', () => { it('should return arrival time for a reachable stop', () => { const arrivalTime = { arrival: timeFromHMS(9, 0, 0), legNumber: 1 }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2], arrivals: [[2, timeFromHMS(9, 0, 0), 1]], }), mockStopsIndex, mockTimetable, ); const arrival = result.arrivalAt(2); assert.deepStrictEqual(arrival, arrivalTime); }); it('should return undefined for unreachable stop', () => { const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2], arrivals: [[2, timeFromHMS(9, 0, 0), 1]], }), mockStopsIndex, mockTimetable, ); const arrival = result.arrivalAt(3); assert.strictEqual(arrival, undefined); }); it('should return earliest arrival among equivalent stops', () => { const earlierArrival = { arrival: timeFromHMS(9, 0, 0), legNumber: 1 }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [4], arrivals: [ [5, timeFromHMS(9, 0, 0), 1], // child1 – faster [6, timeFromHMS(9, 30, 0), 1], // child2 – slower ], }), mockStopsIndex, mockTimetable, ); const arrival = result.arrivalAt(4); assert.deepStrictEqual(arrival, earlierArrival); }); it('should respect maxTransfers constraint', () => { const directArrival = { arrival: timeFromHMS(9, 30, 0), legNumber: 1 }; const transferArrival = { arrival: timeFromHMS(9, 0, 0), legNumber: 2 }; const vehicleEdge1: VehicleEdge = { arrival: timeFromHMS(9, 30, 0), stopIndex: 0, hopOffStopIndex: 2, routeId: 0, tripIndex: 0, }; const vehicleEdge2: VehicleEdge = { arrival: timeFromHMS(9, 45, 0), stopIndex: 0, hopOffStopIndex: 1, routeId: 1, tripIndex: 0, }; const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, origins: [0], destinations: [2], // The best overall arrival at stop 2 is 9:00 (leg 2), coming from a // route with a transfer — even though the graph edges themselves arrive // later. This tests that arrivalAt respects the maxTransfers limit by // falling back to the graph rather than the global best. arrivals: [[2, timeFromHMS(9, 0, 0), 2]], graph: [ [[0, { stopId: 0, arrival: timeFromHMS(8, 0, 0) }]], // round 0 – origins [[2, vehicleEdge1]], // round 1 – direct (9:30) [[2, vehicleEdge2]], // round 2 – with transfer ], }), mockStopsIndex, mockTimetable, ); // With maxTransfers = 0, only round 1 is considered → direct arrival. const arrivalWithLimit = result.arrivalAt(2, 0); assert.deepStrictEqual(arrivalWithLimit, directArrival); // Without a limit, the global best is returned. const arrivalWithoutLimit = result.arrivalAt(2); assert.deepStrictEqual(arrivalWithoutLimit, transferArrival); }); it('should handle non-existent stops', () => { const result = new Result( mockQuery.to, RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2], arrivals: [[2, timeFromHMS(9, 0, 0), 1]], }), mockStopsIndex, mockTimetable, ); const arrival = result.arrivalAt(324); assert.strictEqual(arrival, undefined); }); }); });