minotor
Version:
A lightweight client-side transit routing library.
623 lines (599 loc) • 17.8 kB
text/typescript
import assert from 'node:assert';
import { describe, it } from 'node:test';
import fs from 'fs';
import type {
ServiceRouteInfo,
TransferType as TransferTypeString,
} from '../router.js';
import {
Query,
RangeQuery,
Route,
Router,
Stop,
StopsIndex,
Timetable,
} from '../router.js';
import { timeFromString, timeToString } from '../timetable/time.js';
type StopRef = { name: string; platform?: string };
type ExpectedVehicleLeg = {
from: StopRef;
to: StopRef;
departure: string;
arrival: string;
route: ServiceRouteInfo;
};
type ExpectedTransferLeg = {
from: StopRef;
to: StopRef;
type: TransferTypeString;
minTransferTime?: number;
};
type ExpectedAccessLeg = {
from: StopRef;
to: StopRef;
duration: number;
};
type ExpectedLeg = ExpectedVehicleLeg | ExpectedTransferLeg | ExpectedAccessLeg;
const stopsPath = new URL('./timetable/stops.bin', import.meta.url).pathname;
const timetablePath = new URL('./timetable/timetable.bin', import.meta.url)
.pathname;
const stopsIndex = StopsIndex.fromData(fs.readFileSync(stopsPath));
const timetable = Timetable.fromData(fs.readFileSync(timetablePath));
const router = new Router(timetable, stopsIndex);
/**
* Finds a station by its exact name. Asserts that exactly one STATION-type
* stop with that name exists so a typo in a test causes an immediate failure.
*/
function findStation(name: string): Stop {
const match = stopsIndex
.findStopsByName(name, 20)
.find((s) => s.name === name && s.locationType === 'STATION');
assert.ok(match, `Station not found: "${name}"`);
return match;
}
/**
* Converts an internal Stop to a StopRef used in expected leg data.
* Platform is omitted when the stop has none, keeping assertions concise.
*/
function toStopRef(stop: Stop): StopRef {
return stop.platform
? { name: stop.name, platform: stop.platform }
: { name: stop.name };
}
/**
* Converts a Route's legs to the name-based format used in assertions.
*/
function toExpectedLegs(route: Route): ExpectedLeg[] {
return route.legs.map((leg) => {
if ('route' in leg) {
return {
from: toStopRef(leg.from),
to: toStopRef(leg.to),
departure: timeToString(leg.departureTime),
arrival: timeToString(leg.arrivalTime),
route: leg.route,
};
} else if ('type' in leg) {
return {
from: toStopRef(leg.from),
to: toStopRef(leg.to),
type: leg.type,
...(leg.minTransferTime !== undefined && {
minTransferTime: leg.minTransferTime,
}),
};
} else {
return {
from: toStopRef(leg.from),
to: toStopRef(leg.to),
duration: leg.duration,
};
}
});
}
const pointRoutes: {
from: string;
to: string;
at: string;
legs: ExpectedLeg[];
}[] = [
{
from: 'Fribourg/Freiburg',
to: 'Moléson-sur-Gruyères',
at: '08:30',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '2' },
to: { name: 'Bulle', platform: '2' },
departure: '08:34',
arrival: '09:10',
route: { type: 'RAIL', name: 'RE2' },
},
{
from: { name: 'Bulle', platform: '2' },
to: { name: 'Bulle', platform: '4' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 3,
},
{
from: { name: 'Bulle', platform: '4' },
to: { name: 'Gruyères', platform: '1' },
departure: '09:20',
arrival: '09:28',
route: { type: 'RAIL', name: 'S51' },
},
{
from: { name: 'Gruyères', platform: '1' },
to: { name: 'Gruyères, gare', platform: 'B' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 2,
},
{
from: { name: 'Gruyères, gare', platform: 'B' },
to: { name: 'Moléson-sur-Gruyères' },
departure: '09:33',
arrival: '09:44',
route: { type: 'BUS', name: '263' },
},
],
},
{
from: 'Bern',
to: 'St. Moritz',
at: '12:30',
legs: [
{
from: { name: 'Bern', platform: '8' },
to: { name: 'Zürich HB', platform: '33' },
departure: '12:31',
arrival: '13:28',
route: { type: 'RAIL', name: 'IC1' },
},
{
from: { name: 'Zürich HB', platform: '33' },
to: { name: 'Zürich HB', platform: '6' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 7,
},
{
from: { name: 'Zürich HB', platform: '6' },
to: { name: 'Chur', platform: '9' },
departure: '13:38',
arrival: '14:52',
route: { type: 'RAIL', name: 'IC3' },
},
{
from: { name: 'Chur', platform: '9' },
to: { name: 'Chur', platform: '10' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 3,
},
{
from: { name: 'Chur', platform: '10' },
to: { name: 'St. Moritz', platform: '1' },
departure: '14:58',
arrival: '16:55',
route: { type: 'RAIL', name: 'IR38' },
},
],
},
{
from: 'Basel SBB',
to: 'Strasbourg',
at: '16:50',
legs: [
{
from: { name: 'Basel SBB', platform: '33' },
to: { name: 'Saint-Louis (Haut-Rhin)' },
departure: '17:08',
arrival: '17:16',
route: { type: 'RAIL', name: 'TER' },
},
{
from: { name: 'Saint-Louis (Haut-Rhin)' },
to: { name: 'Strasbourg' },
departure: '17:30',
arrival: '18:39',
route: { type: 'RAIL', name: 'K200' },
},
],
},
{
from: 'Fribourg/Freiburg',
to: 'Davos Platz',
at: '08:30',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '3' },
to: { name: 'Bern', platform: '10' },
departure: '08:33',
arrival: '08:56',
route: { type: 'RAIL', name: 'IR15' },
},
{
from: { name: 'Bern', platform: '10' },
to: { name: 'Bern', platform: '2' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 6,
},
{
from: { name: 'Bern', platform: '2' },
to: { name: 'Zürich HB', platform: '34' },
departure: '09:02',
arrival: '09:58',
route: { type: 'RAIL', name: 'IC81' },
},
{
from: { name: 'Zürich HB', platform: '34' },
to: { name: 'Zürich HB', platform: '10' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 7,
},
{
from: { name: 'Zürich HB', platform: '10' },
to: { name: 'Landquart', platform: '2' },
departure: '10:07',
arrival: '11:11',
route: { type: 'RAIL', name: 'IC3' },
},
{
from: { name: 'Landquart', platform: '2' },
to: { name: 'Landquart', platform: '6' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 4,
},
{
from: { name: 'Landquart', platform: '6' },
to: { name: 'Davos Platz', platform: '2' },
departure: '11:20',
arrival: '12:27',
route: { type: 'RAIL', name: 'RE13' },
},
],
},
{
from: 'Fribourg/Freiburg',
to: 'Plan-Francey',
at: '09:00',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '2' },
to: { name: 'Bulle', platform: '2' },
departure: '09:04',
arrival: '09:40',
route: { type: 'RAIL', name: 'RE3' },
},
{
from: { name: 'Bulle', platform: '2' },
to: { name: 'Bulle', platform: '4' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 3,
},
{
from: { name: 'Bulle', platform: '4' },
to: { name: 'Gruyères', platform: '2' },
departure: '09:50',
arrival: '09:57',
route: { type: 'RAIL', name: 'S50' },
},
{
from: { name: 'Gruyères', platform: '2' },
to: { name: 'Gruyères, gare', platform: 'B' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 2,
},
{
from: { name: 'Gruyères, gare', platform: 'B' },
to: { name: 'Moléson-sur-Gruyères' },
departure: '10:33',
arrival: '10:44',
route: { type: 'BUS', name: '263' },
},
{
from: { name: 'Moléson-sur-Gruyères' },
to: { name: 'Moléson-sur-Gruyères (funi)' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 2,
},
{
from: { name: 'Moléson-sur-Gruyères (funi)' },
to: { name: 'Plan-Francey' },
departure: '11:00',
arrival: '11:05',
route: { type: 'FUNICULAR', name: 'FUN' },
},
],
},
];
const rangeRoutes: {
from: string;
to: string;
earliest: string;
latest: string;
// Runs are listed latest-departure-first, matching Range RAPTOR's natural order.
runs: { departureTime: string; legs: ExpectedLeg[] }[];
}[] = [
{
from: 'Fribourg/Freiburg',
to: 'Moléson-sur-Gruyères',
earliest: '08:00',
latest: '10:00',
runs: [
{
departureTime: '09:34',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '2' },
to: { name: 'Bulle', platform: '2' },
departure: '09:34',
arrival: '10:10',
route: { type: 'RAIL', name: 'RE2' },
},
{
from: { name: 'Bulle', platform: '2' },
to: { name: 'Bulle', platform: '4' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 3,
},
{
from: { name: 'Bulle', platform: '4' },
to: { name: 'Gruyères', platform: '1' },
departure: '10:20',
arrival: '10:28',
route: { type: 'RAIL', name: 'S51' },
},
{
from: { name: 'Gruyères', platform: '1' },
to: { name: 'Gruyères, gare', platform: 'B' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 2,
},
{
from: { name: 'Gruyères, gare', platform: 'B' },
to: { name: 'Moléson-sur-Gruyères' },
departure: '10:33',
arrival: '10:44',
route: { type: 'BUS', name: '263' },
},
],
},
{
departureTime: '08:34',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '2' },
to: { name: 'Bulle', platform: '2' },
departure: '08:34',
arrival: '09:10',
route: { type: 'RAIL', name: 'RE2' },
},
{
from: { name: 'Bulle', platform: '2' },
to: { name: 'Bulle', platform: '4' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 3,
},
{
from: { name: 'Bulle', platform: '4' },
to: { name: 'Gruyères', platform: '1' },
departure: '09:20',
arrival: '09:28',
route: { type: 'RAIL', name: 'S51' },
},
{
from: { name: 'Gruyères', platform: '1' },
to: { name: 'Gruyères, gare', platform: 'B' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 2,
},
{
from: { name: 'Gruyères, gare', platform: 'B' },
to: { name: 'Moléson-sur-Gruyères' },
departure: '09:33',
arrival: '09:44',
route: { type: 'BUS', name: '263' },
},
],
},
],
},
{
from: 'Basel SBB',
to: 'Strasbourg',
earliest: '16:00',
latest: '18:00',
runs: [
{
departureTime: '17:21',
legs: [
{
from: { name: 'Basel SBB', platform: '31' },
to: { name: 'Strasbourg' },
departure: '17:21',
arrival: '18:39',
route: { type: 'RAIL', name: 'TER' },
},
],
},
{
departureTime: '16:38',
legs: [
{
from: { name: 'Basel SBB', platform: '33' },
to: { name: 'Saint-Louis (Haut-Rhin)' },
departure: '16:38',
arrival: '16:46',
route: { type: 'RAIL', name: 'TER' },
},
{
from: { name: 'Saint-Louis (Haut-Rhin)' },
to: { name: 'Strasbourg' },
departure: '16:54',
arrival: '18:09',
route: { type: 'RAIL', name: 'K200' },
},
],
},
],
},
{
from: 'Fribourg/Freiburg',
to: 'Davos Platz',
earliest: '08:00',
latest: '09:00',
runs: [
{
departureTime: '08:33',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '3' },
to: { name: 'Bern', platform: '10' },
departure: '08:33',
arrival: '08:56',
route: { type: 'RAIL', name: 'IR15' },
},
{
from: { name: 'Bern', platform: '10' },
to: { name: 'Bern', platform: '2' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 6,
},
{
from: { name: 'Bern', platform: '2' },
to: { name: 'Zürich HB', platform: '34' },
departure: '09:02',
arrival: '09:58',
route: { type: 'RAIL', name: 'IC81' },
},
{
from: { name: 'Zürich HB', platform: '34' },
to: { name: 'Zürich HB', platform: '10' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 7,
},
{
from: { name: 'Zürich HB', platform: '10' },
to: { name: 'Landquart', platform: '2' },
departure: '10:07',
arrival: '11:11',
route: { type: 'RAIL', name: 'IC3' },
},
{
from: { name: 'Landquart', platform: '2' },
to: { name: 'Landquart', platform: '6' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 4,
},
{
from: { name: 'Landquart', platform: '6' },
to: { name: 'Davos Platz', platform: '2' },
departure: '11:20',
arrival: '12:27',
route: { type: 'RAIL', name: 'RE13' },
},
],
},
{
departureTime: '08:03',
legs: [
{
from: { name: 'Fribourg/Freiburg', platform: '3' },
to: { name: 'Zürich HB', platform: '33' },
departure: '08:03',
arrival: '09:28',
route: { type: 'RAIL', name: 'IC1' },
},
{
from: { name: 'Zürich HB', platform: '33' },
to: { name: 'Zürich HB', platform: '4' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 7,
},
{
from: { name: 'Zürich HB', platform: '4' },
to: { name: 'Landquart', platform: '2' },
departure: '09:38',
arrival: '10:41',
route: { type: 'RAIL', name: 'IC3' },
},
{
from: { name: 'Landquart', platform: '2' },
to: { name: 'Landquart', platform: '6' },
type: 'REQUIRES_MINIMAL_TIME',
minTransferTime: 4,
},
{
from: { name: 'Landquart', platform: '6' },
to: { name: 'Davos Platz', platform: '1' },
departure: '10:49',
arrival: '12:03',
route: { type: 'RAIL', name: 'RE24' },
},
],
},
],
},
];
describe('E2E Tests for Transit Router', () => {
describe('point queries', () => {
for (const { from, to, at, legs } of pointRoutes) {
it(`${from} → ${to} at ${at}`, () => {
const fromStation = findStation(from);
const toStation = findStation(to);
const result = router.route(
new Query.Builder()
.from(fromStation.id)
.to(toStation.id)
.departureTime(timeFromString(at))
.maxTransfers(5)
.build(),
);
const bestRoute = result.bestRoute(toStation.id);
assert.ok(bestRoute, 'No route found');
assert.deepStrictEqual(toExpectedLegs(bestRoute), legs);
});
}
});
describe('range queries', () => {
for (const { from, to, earliest, latest, runs } of rangeRoutes) {
it(`${from} → ${to} [${earliest}–${latest}]`, () => {
const fromStation = findStation(from);
const toStation = findStation(to);
const result = router.rangeRoute(
new RangeQuery.Builder()
.from(fromStation.id)
.to(toStation.id)
.departureTime(timeFromString(earliest))
.lastDepartureTime(timeFromString(latest))
.maxTransfers(5)
.build(),
);
assert.strictEqual(
result.size,
runs.length,
`Expected ${runs.length} Pareto-optimal run(s), got ${result.size}`,
);
const actualRuns = [...result];
for (let i = 0; i < runs.length; i++) {
const actualRun = actualRuns[i];
const expectedRun = runs[i];
assert.ok(actualRun, `Run ${i}: missing actual run`);
assert.ok(expectedRun, `Run ${i}: missing expected run`);
assert.strictEqual(
timeToString(actualRun.departureTime),
expectedRun.departureTime,
`Run ${i}: departure time mismatch`,
);
const route = actualRun.result.bestRoute(toStation.id);
assert.ok(route, `Run ${i}: no route found`);
assert.deepStrictEqual(
toExpectedLegs(route),
expectedRun.legs,
`Run ${i}: legs mismatch`,
);
}
});
}
});
});