minotor
Version:
A lightweight client-side transit routing library.
1,658 lines (1,511 loc) • 48.5 kB
text/typescript
import assert from 'node:assert';
import { beforeEach, describe, it } from 'node:test';
import { Stop } from '../../stops/stops.js';
import { StopsIndex } from '../../stops/stopsIndex.js';
import { Route } from '../../timetable/route.js';
import { durationFromSeconds, timeFromHM } from '../../timetable/time.js';
import {
RouteTypes,
ServiceRoute,
StopAdjacency,
Timetable,
TransferTypes,
TripTransfers,
} from '../../timetable/timetable.js';
import { encode } from '../../timetable/tripStopId.js';
import { AccessFinder } from '../access.js';
import { PlainRouter } from '../plainRouter.js';
import { Query } from '../query.js';
import { Raptor } from '../raptor.js';
import { Result } from '../result.js';
describe('PlainRouter', () => {
describe('with a single route', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: A single route (Line 1) serving 3 stops in sequence
// Route 0: stop1 (depart 08:10) -> stop2 (08:15-08:25) -> stop3 (arrive 08:35)
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0] }, // stop 1 (stop2)
{ routes: [0] }, // stop 2 (stop3)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1 -> 2
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 15),
departureTime: timeFromHM(8, 25),
},
{
id: 2,
arrivalTime: timeFromHM(8, 35),
departureTime: timeFromHM(8, 45),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should find a direct route', () => {
const query = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a single-leg direct route on Line 1: stop1 -> stop3
assert.strictEqual(bestRoute?.legs.length, 1);
});
it('should return an empty result when no route is possible', () => {
const query = new Query.Builder()
.from(0)
.to(12)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// No route exists to a non-existent stop
assert.strictEqual(bestRoute, undefined);
});
it('should correctly calculate the arrival time to a stop', () => {
const query = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const timeToStop3 = result.arrivalAt(2);
// Route 0 arrives at stop3 at 08:35
assert.strictEqual(timeToStop3?.arrival, timeFromHM(8, 35));
});
it('should not return journeys arriving after maxDuration', () => {
const tooShort = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.maxDuration(34)
.build();
assert.strictEqual(router.route(tooShort).bestRoute(), undefined);
const justEnough = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.maxDuration(35)
.build();
const route = router.route(justEnough).bestRoute();
assert(route);
assert.strictEqual(route.arrivalTime(), timeFromHM(8, 35));
});
});
describe('with a route change', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Two routes that share stop2, enabling a same-stop transfer (route change)
// Route 0 (Line 1): stop1 (depart 08:15) -> stop2 (08:30-08:45) -> stop3 (09:00)
// Route 1 (Line 2): stop4 (depart 08:20) -> stop2 (09:00-09:15) -> stop5 (09:20)
// Both routes serve stop2, allowing transfer without walking
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0, 1] }, // stop 1 (stop2) - shared by both routes
{ routes: [0] }, // stop 2 (stop3)
{ routes: [1] }, // stop 3 (stop4)
{ routes: [1] }, // stop 4 (stop5)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1 -> 2
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 15),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 45),
},
{
id: 2,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 10),
},
],
},
],
}),
// Route 1: stops 3 -> 1 -> 4
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 3,
arrivalTime: timeFromHM(8, 5),
departureTime: timeFromHM(8, 20),
},
{
id: 1,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 15),
},
{
id: 4,
arrivalTime: timeFromHM(9, 20),
departureTime: timeFromHM(9, 35),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.RAIL,
name: 'Line 2',
routes: [1],
},
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 3,
sourceStopId: 'stop4',
name: 'Stop 4',
lat: 4.0,
lon: 4.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 4,
sourceStopId: 'stop5',
name: 'Stop 5',
lat: 5.0,
lon: 5.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should find a route with a change', () => {
const query = new Query.Builder()
.from(0)
.to(4)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with 2 legs:
// 1. Line 1: stop1 -> stop2 (arrive 08:30)
// 2. Line 2: stop2 -> stop5 (depart 09:15, arrive 09:20)
// Transfer at stop2 with 30 minutes to spare (default minTransferTime is 2 min)
assert.strictEqual(bestRoute?.legs.length, 2);
});
it('should correctly calculate the arrival time to a stop', () => {
const query = new Query.Builder()
.from(0)
.to(4)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const timeToStop5 = result.arrivalAt(4);
// Line 2 arrives at stop5 at 09:20
assert.strictEqual(timeToStop5?.arrival, timeFromHM(9, 20));
});
});
describe('with a walking transfer', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Two routes that don't share any stops, connected by a walking transfer
// Route 0 (Line 1): stop1 (depart 08:15) -> stop2 (08:25-08:35) -> stop3 (08:45)
// Route 1 (Line 2): stop4 (depart 08:20) -> stop5 (08:40-08:50) -> stop6 (09:10)
// Walking transfer from stop2 to stop5 with 5 minute minTransferTime
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{
transfers: [
{
destination: 4,
type: TransferTypes.REQUIRES_MINIMAL_TIME,
minTransferTime: durationFromSeconds(300), // 5 minutes walking
},
],
routes: [0],
}, // stop 1 (stop2) - has walking transfer to stop5
{ routes: [0] }, // stop 2 (stop3)
{ routes: [1] }, // stop 3 (stop4)
{ routes: [1] }, // stop 4 (stop5)
{ routes: [1] }, // stop 5 (stop6)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1 -> 2
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 15),
},
{
id: 1,
arrivalTime: timeFromHM(8, 25),
departureTime: timeFromHM(8, 35),
},
{
id: 2,
arrivalTime: timeFromHM(8, 45),
departureTime: timeFromHM(8, 55),
},
],
},
],
}),
// Route 1: stops 3 -> 4 -> 5
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 3,
arrivalTime: timeFromHM(8, 10),
departureTime: timeFromHM(8, 20),
},
{
id: 4,
arrivalTime: timeFromHM(8, 40),
departureTime: timeFromHM(8, 50),
},
{
id: 5,
arrivalTime: timeFromHM(9, 10),
departureTime: timeFromHM(9, 10),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.RAIL,
name: 'Line 2',
routes: [1],
},
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 3,
sourceStopId: 'stop4',
name: 'Stop 4',
lat: 4.0,
lon: 4.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 4,
sourceStopId: 'stop5',
name: 'Stop 5',
lat: 5.0,
lon: 5.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 5,
sourceStopId: 'stop6',
name: 'Stop 6',
lat: 6.0,
lon: 6.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should find a route with a walking transfer', () => {
const query = new Query.Builder()
.from(0)
.to(5)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with 3 legs:
// 1. Line 1: stop1 -> stop2 (arrive 08:25)
// 2. Walking transfer: stop2 -> stop5 (5 min walk, arrive 08:30)
// 3. Line 2: stop5 -> stop6 (depart 08:50, arrive 09:10)
assert.strictEqual(bestRoute?.legs.length, 3);
});
it('should correctly calculate the arrival time at intermediate stop', () => {
const query = new Query.Builder()
.from(0)
.to(5)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const timeToStop5 = result.arrivalAt(4);
// Arrive at stop2 at 08:25, walk 5 min to stop5, arrive at 08:30
assert.strictEqual(timeToStop5?.arrival, timeFromHM(8, 30));
});
});
describe('with a faster change', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Three routes where Route 2 is direct but slower than Route 0 + Route 1 with a change
// Route 0 (Line 1): stop1 (08:15) -> stop2 (08:30-08:45) -> stop3 (09:00)
// Route 1 (Line 2): stop4 (08:25) -> stop2 (08:50-09:05) -> stop5 (09:10)
// Route 2 (Line 3): stop1 (08:15) -> stop5 (09:45) - direct but slower
// The router should prefer Route 0 + Route 1 (arrive 09:10) over Route 2 (arrive 09:45)
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0, 2] }, // stop 0 (stop1) - served by Line 1 and Line 3
{ routes: [0, 1] }, // stop 1 (stop2) - transfer point
{ routes: [0] }, // stop 2 (stop3)
{ routes: [1] }, // stop 3 (stop4)
{ routes: [1, 2] }, // stop 4 (stop5) - destination
];
const routesAdjacency = [
// Route 0: stops 0 -> 1 -> 2
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 15),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 45),
},
{
id: 2,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 15),
},
],
},
],
}),
// Route 1: stops 3 -> 1 -> 4
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 3,
arrivalTime: timeFromHM(8, 10),
departureTime: timeFromHM(8, 25),
},
{
id: 1,
arrivalTime: timeFromHM(8, 50),
departureTime: timeFromHM(9, 5),
},
{
id: 4,
arrivalTime: timeFromHM(9, 10),
departureTime: timeFromHM(9, 25),
},
],
},
],
}),
// Route 2: stops 0 -> 4 (direct but slow)
Route.of({
id: 2,
serviceRouteId: 2,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 15),
},
{
id: 4,
arrivalTime: timeFromHM(9, 45),
departureTime: timeFromHM(10, 0),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.RAIL,
name: 'Line 2',
routes: [1],
},
{
type: RouteTypes.FERRY,
name: 'Line 3',
routes: [2],
},
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 3,
sourceStopId: 'stop4',
name: 'Stop 4',
lat: 4.0,
lon: 4.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 4,
sourceStopId: 'stop5',
name: 'Stop 5',
lat: 5.0,
lon: 5.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should prefer a faster route with a change over a slower direct route', () => {
const query = new Query.Builder()
.from(0)
.to(4)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should prefer the 2-leg route (Line 1 + Line 2, arrive 09:10)
// over the 1-leg direct route (Line 3, arrive 09:45)
assert.strictEqual(bestRoute?.legs.length, 2);
});
});
describe('with route continuation (in-seat transfer)', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Route 0 continues as Route 1 at stop2 (same vehicle, different route number)
// This is an "in-seat transfer" where passengers can stay on the vehicle
// Route 0 (Line 1): stop1 (depart 08:10) -> stop2 (08:15-08:25)
// Route 1 (Line 2): stop2 (08:15-08:25) -> stop3 (08:35) -> stop4 (08:55)
// Trip continuation: Route 0 trip 0 at stop2 continues as Route 1 trip 0
// encode(1, 0, 0) = stop index 1 on route 0, trip 0 (where the continuation starts)
const tripContinuations: TripTransfers = new Map([
[encode(1, 0, 0), [{ stopIndex: 0, routeId: 1, tripIndex: 0 }]],
]);
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0, 1] }, // stop 1 (stop2) - continuation point
{ routes: [1] }, // stop 2 (stop3)
{ routes: [1] }, // stop 3 (stop4)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 15),
departureTime: timeFromHM(8, 25),
},
],
},
],
}),
// Route 1: stops 1 -> 2 -> 3 (continuation from route 0)
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 15),
departureTime: timeFromHM(8, 25),
},
{
id: 2,
arrivalTime: timeFromHM(8, 35),
departureTime: timeFromHM(8, 45),
},
{
id: 3,
arrivalTime: timeFromHM(8, 55),
departureTime: timeFromHM(9, 5),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.BUS,
name: 'Line 2',
routes: [1],
},
];
timetable = new Timetable(
stopsAdjacency,
routesAdjacency,
routes,
tripContinuations,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 3,
sourceStopId: 'stop4',
name: 'Stop 4',
lat: 4.0,
lon: 4.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should find a route using continuation (in-seat transfer)', () => {
const query = new Query.Builder()
.from(0)
.to(3)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with only 1 leg because the continuation allows
// staying on the same vehicle when it changes route numbers
assert.strictEqual(bestRoute?.legs.length, 1);
});
it('should correctly calculate arrival time with continuation', () => {
const query = new Query.Builder()
.from(0)
.to(3)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
const timeToStop4 = result.arrivalAt(3);
// Route 1 (continuation of Route 0) arrives at stop4 at 08:55
assert.strictEqual(timeToStop4?.arrival, timeFromHM(8, 55));
});
});
describe('with guaranteed trip transfers', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Route 0 trip 0 has a guaranteed transfer to Route 1 trip 0 at stop2
// The transfer time is only 1 minute (60 seconds), less than the 5-minute minTransferTime
// But since it's guaranteed, it should still be considered
// Route 0: stop1 (depart 08:10) -> stop2 (arrive 08:20)
// Route 1: stop2 (depart 08:21) -> stop3 (arrive 08:40)
// encode(1, 0, 0) = stop index 1 on route 0, trip 0 (where we alight)
// destination { stopIndex: 0, routeId: 1, tripIndex: 0 } = stop index 0 on route 1, trip 0 (where we board)
const guaranteedTripTransfers: TripTransfers = new Map([
[encode(1, 0, 0), [{ stopIndex: 0, routeId: 1, tripIndex: 0 }]],
]);
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0, 1] }, // stop 1 (stop2) - both routes serve this stop
{ routes: [1] }, // stop 2 (stop3)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 30),
},
],
},
],
}),
// Route 1: stops 1 -> 2
// Departure at 08:21, only 1 minute after arrival from route 0
// Without the guaranteed transfer, this would be missed with a 5-minute minTransferTime
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 15),
departureTime: timeFromHM(8, 21),
},
{
id: 2,
arrivalTime: timeFromHM(8, 40),
departureTime: timeFromHM(8, 50),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.BUS,
name: 'Line 2',
routes: [1],
},
];
timetable = new Timetable(
stopsAdjacency,
routesAdjacency,
routes,
undefined, // no trip continuations
guaranteedTripTransfers,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should consider guaranteed transfer even with less time than minTransferTime', () => {
const query = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.minTransferTime(durationFromSeconds(300)) // 5 minutes, but transfer only has 1 minute
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with 3 legs:
// 1. Vehicle leg (route 0)
// 2. Guaranteed transfer leg (shown as type: 'GUARANTEED')
// 3. Vehicle leg (route 1)
assert.ok(bestRoute);
assert.strictEqual(bestRoute.legs.length, 3);
// The middle leg should be the guaranteed transfer
const transferLeg = bestRoute.legs[1];
assert.ok(transferLeg && 'type' in transferLeg);
assert.strictEqual(transferLeg.type, 'GUARANTEED');
// Should arrive at 08:40 because the guaranteed transfer allows catching
// the 08:21 departure despite only having 1 minute of transfer time
assert.strictEqual(result.arrivalAt(2)?.arrival, timeFromHM(8, 40));
});
});
describe('with non-guaranteed transfers and minTransferTime', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Same-stop transfer (route change) without guaranteed transfer
// Both routes serve stop2, so this is a route change at the same stop
// The query's minTransferTime should be respected
// Route 0: stop1 (depart 08:10) -> stop2 (arrive 08:20)
// Route 1 trip 0: stop2 (depart 08:21) -> stop3 (arrive 08:35) - NOT catchable with 5 min minTransferTime
// Route 1 trip 1: stop2 (depart 08:26) -> stop3 (arrive 08:45) - catchable with 5 min minTransferTime
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0, 1] }, // stop 1 (stop2) - both routes serve this stop
{ routes: [1] }, // stop 2 (stop3)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 30),
},
],
},
],
}),
// Route 1: stops 1 -> 2
// Trip 0: Departure at 08:21, only 1 minute after arrival - should NOT be catchable with 5 min minTransferTime
// Trip 1: Departure at 08:26, 6 minutes after arrival - should be catchable with 5 min minTransferTime
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 15),
departureTime: timeFromHM(8, 21),
},
{
id: 2,
arrivalTime: timeFromHM(8, 35),
departureTime: timeFromHM(8, 45),
},
],
},
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 26),
},
{
id: 2,
arrivalTime: timeFromHM(8, 45),
departureTime: timeFromHM(8, 55),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{
type: RouteTypes.BUS,
name: 'Line 1',
routes: [0],
},
{
type: RouteTypes.BUS,
name: 'Line 2',
routes: [1],
},
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should not consider transfer with less time than minTransferTime', () => {
const query = new Query.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.minTransferTime(durationFromSeconds(300)) // 5 minutes required for transfer
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with 2 legs (same-stop transfer, no walking):
// 1. Vehicle leg on route 0 (stop1 -> stop2)
// 2. Vehicle leg on route 1 (stop2 -> stop3)
assert.strictEqual(bestRoute?.legs.length, 2);
// Arrival at stop2 is 08:20, with 5 min minTransferTime we need departure >= 08:25
// Trip 0 of route 1 departs at 08:21 - NOT catchable (only 1 minute transfer time)
// Trip 1 of route 1 departs at 08:26 - catchable (6 minutes transfer time)
// So we should arrive at stop3 at 08:45 (trip 1 arrival), not 08:35 (trip 0 arrival)
assert.strictEqual(result.arrivalAt(2)?.arrival, timeFromHM(8, 45));
});
});
describe('with maxTransfers constraint', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Three routes where reaching stop4 requires 2 transfers
// Route 0: stop1 -> stop2
// Route 1: stop2 -> stop3
// Route 2: stop3 -> stop4
// With maxTransfers=1, stop4 should not be reachable
// With maxTransfers=2, stop4 should be reachable
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] }, // stop 0 (stop1)
{ routes: [0, 1] }, // stop 1 (stop2)
{ routes: [1, 2] }, // stop 2 (stop3)
{ routes: [2] }, // stop 3 (stop4)
];
const routesAdjacency = [
// Route 0: stops 0 -> 1
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 30),
},
],
},
],
}),
// Route 1: stops 1 -> 2
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 25),
departureTime: timeFromHM(8, 35),
},
{
id: 2,
arrivalTime: timeFromHM(8, 45),
departureTime: timeFromHM(8, 55),
},
],
},
],
}),
// Route 2: stops 2 -> 3
Route.of({
id: 2,
serviceRouteId: 2,
trips: [
{
stops: [
{
id: 2,
arrivalTime: timeFromHM(8, 50),
departureTime: timeFromHM(9, 0),
},
{
id: 3,
arrivalTime: timeFromHM(9, 10),
departureTime: timeFromHM(9, 20),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
{ type: RouteTypes.BUS, name: 'Line 2', routes: [1] },
{ type: RouteTypes.BUS, name: 'Line 3', routes: [2] },
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'stop3',
name: 'Stop 3',
lat: 3.0,
lon: 3.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 3,
sourceStopId: 'stop4',
name: 'Stop 4',
lat: 4.0,
lon: 4.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should not find route when maxTransfers is too low', () => {
const query = new Query.Builder()
.from(0)
.to(3)
.departureTime(timeFromHM(8, 0))
.maxTransfers(1) // Only allows 1 transfer, but we need 2
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should not find a route because reaching stop4 requires 2 transfers
assert.strictEqual(bestRoute, undefined);
});
it('should find route when maxTransfers is sufficient', () => {
const query = new Query.Builder()
.from(0)
.to(3)
.departureTime(timeFromHM(8, 0))
.maxTransfers(2) // Allows 2 transfers, which is exactly what we need
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// Should find a route with 3 legs (2 transfers)
assert.strictEqual(bestRoute?.legs.length, 3);
});
it('should find intermediate stops even with low maxTransfers', () => {
const query = new Query.Builder()
.from(0)
.to(3)
.departureTime(timeFromHM(8, 0))
.maxTransfers(1)
.build();
const result: Result = router.route(query);
// stop3 should still be reachable with 1 transfer (stop1 -> stop2 -> stop3)
const arrivalAtStop3 = result.arrivalAt(2);
assert.ok(arrivalAtStop3);
assert.strictEqual(arrivalAtStop3.arrival, timeFromHM(8, 45));
});
});
describe('with transport mode filtering', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: Two routes to the same destination with different transport modes
// Route 0 (BUS): stop1 -> stop2, arrives 08:30
// Route 1 (RAIL): stop1 -> stop2, arrives 08:20 (faster)
// When filtering to BUS only, should use the slower bus route
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0, 1] }, // stop 0 (stop1) - served by both routes
{ routes: [0, 1] }, // stop 1 (stop2) - served by both routes
];
const routesAdjacency = [
// Route 0 (BUS): stops 0 -> 1, slower
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 40),
},
],
},
],
}),
// Route 1 (RAIL): stops 0 -> 1, faster
Route.of({
id: 1,
serviceRouteId: 1,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 30),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Bus Line', routes: [0] },
{ type: RouteTypes.RAIL, name: 'Rail Line', routes: [1] },
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should use fastest route when all modes allowed', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
// Should use the faster RAIL route, arriving at 08:20
assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 20));
});
it('should only use allowed transport modes', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.transportModes(new Set([RouteTypes.BUS] as const))
.build();
const result: Result = router.route(query);
// Should use the slower BUS route since RAIL is excluded, arriving at 08:30
assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
});
it('should return no route when no matching transport mode', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.transportModes(new Set([RouteTypes.FERRY] as const)) // Neither route is a ferry
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// No route should be found since neither BUS nor RAIL is allowed
assert.strictEqual(bestRoute, undefined);
});
});
describe('with timing edge cases', () => {
let router: PlainRouter;
let timetable: Timetable;
beforeEach(() => {
// Setup: A single route with multiple trips at different times
// Trip 0: departs 08:10, arrives 08:30
// Trip 1: departs 09:10, arrives 09:30
const stopsAdjacency: StopAdjacency[] = [
{ routes: [0] },
{ routes: [0] },
];
const routesAdjacency = [
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 10),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 40),
},
],
},
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 10),
},
{
id: 1,
arrivalTime: timeFromHM(9, 30),
departureTime: timeFromHM(9, 40),
},
],
},
],
}),
];
const routes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop1',
name: 'Stop 1',
lat: 1.0,
lon: 1.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'stop2',
name: 'Stop 2',
lat: 2.0,
lon: 2.0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should find first available trip after departure time', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.build();
const result: Result = router.route(query);
// Should catch the first trip (08:10), arriving at 08:30
assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
});
it('should skip trips that have already departed', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 15)) // After first trip departs
.build();
const result: Result = router.route(query);
// Should catch the second trip (09:10), arriving at 09:30
assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(9, 30));
});
it('should return no route when departing after all trips', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(10, 0)) // After all trips have departed
.build();
const result: Result = router.route(query);
const bestRoute = result.bestRoute();
// No route should be found since all trips have departed
assert.strictEqual(bestRoute, undefined);
});
it('should catch trip when departure time exactly matches', () => {
const query = new Query.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 10)) // Exactly when first trip departs
.build();
const result: Result = router.route(query);
// Should still catch the first trip
assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
});
});
});