UNPKG

minotor

Version:

A lightweight client-side transit routing library.

515 lines (480 loc) 16.2 kB
/* eslint-disable @typescript-eslint/no-non-null-assertion */ import assert from 'node:assert'; import { describe, it } from 'node:test'; import { Route } from '../../timetable/route.js'; import { timeFromHM } from '../../timetable/time.js'; import { ALL_TRANSPORT_MODES, RouteTypes, ServiceRoute, StopAdjacency, Timetable, TransferTypes, TripTransfers, } from '../../timetable/timetable.js'; import { encode } from '../../timetable/tripStopId.js'; import { QueryOptions } from '../query.js'; import { Raptor } from '../raptor.js'; import { RoutingState } from '../state.js'; // ─── Base fixture ───────────────────────────────────────────────────────────── // 3 stops: 0 (origin), 1 (transfer stop), 2 (destination) // Route 0 (BUS): 0→1 depart 08:10 / arrive 08:30 // Route 1 (BUS): 1→2 depart 08:35 / arrive 08:50 // Stop adjacency: stop 0 → [0], stop 1 → [0, 1], stop 2 → [1] const NB_STOPS = 3; const route0 = Route.of({ id: 0, serviceRouteId: 0, trips: [ { stops: [ { id: 0, arrivalTime: timeFromHM(8, 10), departureTime: timeFromHM(8, 10), }, { id: 1, arrivalTime: timeFromHM(8, 30), departureTime: timeFromHM(8, 30), }, ], }, ], }); const route1 = Route.of({ id: 1, serviceRouteId: 1, trips: [ { stops: [ { id: 1, arrivalTime: timeFromHM(8, 35), departureTime: timeFromHM(8, 35), }, { id: 2, arrivalTime: timeFromHM(8, 50), departureTime: timeFromHM(8, 50), }, ], }, ], }); const stopsAdjacency: StopAdjacency[] = [ { routes: [0] }, // stop 0: origin { routes: [0, 1] }, // stop 1: transfer stop { routes: [1] }, // stop 2: destination ]; const serviceRoutes: ServiceRoute[] = [ { type: RouteTypes.BUS, name: 'Route 0', routes: [0] }, { type: RouteTypes.BUS, name: 'Route 1', routes: [1] }, ]; // ─── Extended fixture: slower direct route 0→2 ──────────────────────────────── // Route 2 (BUS): 0→2 depart 08:10 / arrive 09:00 const route2 = Route.of({ id: 2, serviceRouteId: 2, trips: [ { stops: [ { id: 0, arrivalTime: timeFromHM(8, 10), departureTime: timeFromHM(8, 10), }, { id: 2, arrivalTime: timeFromHM(9, 0), departureTime: timeFromHM(9, 0), }, ], }, ], }); const stopsAdjacencyWithDirectRoute: StopAdjacency[] = [ { routes: [0, 2] }, // stop 0 { routes: [0, 1] }, // stop 1 { routes: [1, 2] }, // stop 2 ]; const serviceRoutesWithDirectRoute: ServiceRoute[] = [ { type: RouteTypes.BUS, name: 'Route 0', routes: [0] }, { type: RouteTypes.BUS, name: 'Route 1', routes: [1] }, { type: RouteTypes.BUS, name: 'Route 2', routes: [2] }, ]; // ─── Extended fixture: walking transfer from stop 1 to stop 2 ───────────────── // Stop 2 has no routes; can only be reached via the 5-minute walk from stop 1. const stopsAdjacencyWithTransfer: StopAdjacency[] = [ { routes: [0] }, { routes: [0], transfers: [ { destination: 2, type: TransferTypes.REQUIRES_MINIMAL_TIME, minTransferTime: 5, }, ], }, { routes: [] }, ]; // ─── Extended fixture: mixed transport modes ────────────────────────────────── // Route 0 is BUS, route 1 is RAIL — used to verify mode filtering. const mixedModeServiceRoutes: ServiceRoute[] = [ { type: RouteTypes.BUS, name: 'Route 0', routes: [0] }, { type: RouteTypes.RAIL, name: 'Route 1', routes: [1] }, ]; // ─── Extended fixture: in-seat trip continuation ────────────────────────────── // Encodes: at stop-index 1 of route 0, trip 0 → continue as route 1, trip 0, // boarding at stop-index 0. const tripContinuations: TripTransfers = new Map([ [encode(1, 0, 0), [{ stopIndex: 0, routeId: 1, tripIndex: 0 }]], ]); // ───────────────────────────────────────────────────────────────────────────── describe('Raptor', () => { describe('route scanning', () => { it('marks the destination with the correct arrival time', () => { const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const state = new RoutingState( timeFromHM(8, 0), [1], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30)); }); it('finds a two-leg journey via a route change', () => { const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30)); assert.strictEqual(state.getArrival(1)?.legNumber, 1); assert.strictEqual(state.getArrival(2)?.arrival, timeFromHM(8, 50)); assert.strictEqual(state.getArrival(2)?.legNumber, 2); }); it('prefers the faster two-leg route over a slower direct route', () => { const timetable = new Timetable( stopsAdjacencyWithDirectRoute, [route0, route1, route2], serviceRoutesWithDirectRoute, ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); // The 0→1→2 journey arrives at 08:50; the direct 0→2 route arrives at 09:00. assert.strictEqual(state.getArrival(2)?.arrival, timeFromHM(8, 50)); }); }); describe('maxTransfers', () => { it('stops after maxTransfers+1 rounds', () => { // maxTransfers=0 means only round 1 runs: stop 1 is reachable (1 leg), // but stop 2 requires a second round and must remain unreached. const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 1, // maxRounds = maxTransfers + 1 ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 0, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30)); assert.strictEqual(state.getArrival(2), undefined); }); }); describe('maxDuration', () => { it('filters vehicle arrivals after the maxDuration cutoff and allows the exact boundary', () => { const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const raptor = new Raptor(timetable); const tooShortOptions: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 29, }; const tooShort = new RoutingState( timeFromHM(8, 0), [1], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, tooShortOptions.maxTransfers + 1, tooShortOptions.maxDuration, ); raptor.run(tooShortOptions, tooShort); assert.strictEqual(tooShort.getArrival(1), undefined); const justEnoughOptions: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 30, }; const justEnough = new RoutingState( timeFromHM(8, 0), [1], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, justEnoughOptions.maxTransfers + 1, justEnoughOptions.maxDuration, ); raptor.run(justEnoughOptions, justEnough); assert.strictEqual(justEnough.getArrival(1)?.arrival, timeFromHM(8, 30)); }); it('keeps intermediate stops reachable while filtering later vehicle arrivals', () => { const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 45, }; const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, options.maxTransfers + 1, options.maxDuration, ); const raptor = new Raptor(timetable); raptor.run(options, state); assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30)); assert.strictEqual(state.getArrival(2), undefined); }); it('filters timed walking transfers after the maxDuration cutoff', () => { const timetable = new Timetable( stopsAdjacencyWithTransfer, [route0], [serviceRoutes[0]!], ); const raptor = new Raptor(timetable); const tooShortOptions: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 34, }; const tooShort = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, tooShortOptions.maxTransfers + 1, tooShortOptions.maxDuration, ); raptor.run(tooShortOptions, tooShort); assert.strictEqual(tooShort.getArrival(1)?.arrival, timeFromHM(8, 30)); assert.strictEqual(tooShort.getArrival(2), undefined); const justEnoughOptions: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 35, }; const justEnough = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, justEnoughOptions.maxTransfers + 1, justEnoughOptions.maxDuration, ); raptor.run(justEnoughOptions, justEnough); assert.strictEqual(justEnough.getArrival(2)?.arrival, timeFromHM(8, 35)); }); it('filters in-seat continuation arrivals after the maxDuration cutoff', () => { const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, tripContinuations, ); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, maxDuration: 45, }; const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, options.maxTransfers + 1, options.maxDuration, ); const raptor = new Raptor(timetable); raptor.run(options, state); assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30)); assert.strictEqual(state.getArrival(2), undefined); }); }); describe('early termination', () => { it('exits when no trips are catchable', () => { // Departing at 09:00 — all trips have already left (route 0 at 08:10, // route 1 at 08:35). No stop should be marked. const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, ); const state = new RoutingState( timeFromHM(9, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(1), undefined); assert.strictEqual(state.getArrival(2), undefined); }); }); describe('walking transfers', () => { it('marks stops reached only via a timed walk', () => { // Only route 0 exists (0→1). Stop 2 has no routes but is reachable from // stop 1 via a 5-minute REQUIRES_MINIMAL_TIME transfer. // Expected: stop 2 arrival = 08:30 + 5 = 08:35. const timetable = new Timetable( stopsAdjacencyWithTransfer, [route0], [serviceRoutes[0]!], ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(2)?.arrival, timeFromHM(8, 35)); }); }); describe('transport mode filtering', () => { it('skips routes of excluded mode', () => { // Route 0 is BUS; route 1 is RAIL. When only RAIL is allowed, route 0 // is filtered out, stop 0 has no eligible route, and stop 1 is never // reached — so neither stop 1 nor stop 2 should be marked. const timetable = new Timetable( stopsAdjacency, [route0, route1], mixedModeServiceRoutes, ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: new Set([RouteTypes.RAIL]), }; raptor.run(options, state); assert.strictEqual(state.getArrival(1), undefined); }); }); describe('in-seat transfer continuations', () => { it('reaches a stop in the same round via a continuation', () => { // Continuation: route 0, trip 0, hop-off stop-index 1 → route 1, trip 0, // boarding at stop-index 0. Because the continuation is processed within // round 1 (no extra round needed), stop 2 should have legNumber === 1 // instead of the legNumber === 2 that a normal two-round journey produces. const timetable = new Timetable( stopsAdjacency, [route0, route1], serviceRoutes, tripContinuations, ); const state = new RoutingState( timeFromHM(8, 0), [2], [{ fromStopId: 0, toStopId: 0, duration: 0 }], NB_STOPS, 6, ); const raptor = new Raptor(timetable); const options: QueryOptions = { maxTransfers: 5, minTransferTime: 2, transportModes: ALL_TRANSPORT_MODES, }; raptor.run(options, state); assert.strictEqual(state.getArrival(2)?.arrival, timeFromHM(8, 50)); assert.strictEqual(state.getArrival(2)?.legNumber, 1); }); }); });