minotor
Version:
A lightweight client-side transit routing library.
628 lines (561 loc) • 18.3 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,
} from '../../timetable/timetable.js';
import { AccessFinder } from '../access.js';
import { RangeQuery } from '../query.js';
import { RangeResult } from '../rangeResult.js';
import { RangeRouter } from '../rangeRouter.js';
import { Raptor } from '../raptor.js';
describe('RangeRouter', () => {
describe('with initial walking access', () => {
let router: RangeRouter;
beforeEach(() => {
const stopsAdjacency: StopAdjacency[] = [
{
routes: [],
transfers: [
{ destination: 1, type: TransferTypes.REQUIRES_MINIMAL_TIME },
],
},
{ routes: [0] },
{ routes: [0] },
];
const routesAdjacency = [
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 1,
arrivalTime: timeFromHM(8, 5),
departureTime: timeFromHM(8, 5),
},
{
id: 2,
arrivalTime: timeFromHM(8, 20),
departureTime: timeFromHM(8, 20),
},
],
},
],
}),
];
const serviceRoutes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
const timetable = new Timetable(
stopsAdjacency,
routesAdjacency,
serviceRoutes,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'origin',
name: 'Origin',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'boarding',
name: 'Boarding',
lat: 1,
lon: 1,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 2,
sourceStopId: 'destination',
name: 'Destination',
lat: 2,
lon: 2,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
router = new RangeRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('should reconstruct the initial access leg with the fallback transfer time', () => {
const query = new RangeQuery.Builder()
.from(0)
.to(2)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 0))
.minTransferTime(durationFromSeconds(300))
.build();
const result = router.rangeRoute(query);
const route = result.bestRoute();
assert(route);
assert.strictEqual(route.legs.length, 2);
assert.strictEqual(route.departureTime(), timeFromHM(8, 0));
assert.strictEqual(route.arrivalTime(), timeFromHM(8, 20));
const firstLeg = route.legs[0];
assert(firstLeg);
assert.strictEqual(firstLeg.from.id, 0);
assert.strictEqual(firstLeg.to.id, 1);
});
});
describe('with multiple Pareto-optimal runs', () => {
// Base timetable: two-stop network, one route with two trips.
// trip 0: stop 0 departs 08:00, stop 1 arrives 08:30
// trip 1: stop 0 departs 08:30, stop 1 arrives 09:00
// Neither trip dominates the other (trip 1 departs later but arrives later).
let timetable: Timetable;
let router: RangeRouter;
beforeEach(() => {
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, 0),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
],
},
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
{
id: 1,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 0),
},
],
},
],
}),
];
const serviceRoutes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
timetable = new Timetable(stopsAdjacency, routesAdjacency, serviceRoutes);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'origin',
name: 'Origin',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'dest',
name: 'Destination',
lat: 0,
lon: 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 RangeRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('returns one run per non-dominated departure', () => {
const query = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.build();
const result = router.rangeRoute(query);
// Both trips are Pareto-optimal.
assert.strictEqual(result.size, 2);
// The latest departure route leaves at 08:30.
const latest = result.latestDepartureRoute();
assert(latest);
assert.strictEqual(latest.departureTime(), timeFromHM(8, 30));
assert.strictEqual(latest.arrivalTime(), timeFromHM(9, 0));
// The best route (earliest arrival) leaves at 08:00.
const best = result.bestRoute();
assert(best);
assert.strictEqual(best.arrivalTime(), timeFromHM(8, 30));
assert.strictEqual(best.departureTime(), timeFromHM(8, 0));
});
it('excludes a departure dominated by a later trip', () => {
// Rebuild with a timetable where:
// trip 0: departs 08:00 → arrives 09:00 (slower)
// trip 1: departs 08:30 → arrives 08:50 (faster; dominates trip 0)
const dominatingAdj: StopAdjacency[] = [{ routes: [0] }, { routes: [0] }];
const dominatingRoutes = [
Route.of({
id: 0,
serviceRouteId: 0,
trips: [
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 0),
departureTime: timeFromHM(8, 0),
},
{
id: 1,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 0),
},
],
},
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
{
id: 1,
arrivalTime: timeFromHM(8, 50),
departureTime: timeFromHM(8, 50),
},
],
},
],
}),
];
const serviceRoutes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
const dominatingTimetable = new Timetable(
dominatingAdj,
dominatingRoutes,
serviceRoutes,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'origin',
name: 'Origin',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'dest',
name: 'Destination',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(dominatingTimetable, stopsIndex);
const raptor = new Raptor(dominatingTimetable);
const dominatingRouter = new RangeRouter(
dominatingTimetable,
stopsIndex,
accessFinder,
raptor,
);
const query = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.build();
const result = dominatingRouter.rangeRoute(query);
// Only trip 1 (08:30 → 08:50) is Pareto-optimal; trip 0 (08:00 → 09:00)
// is dominated because it departs earlier yet arrives later.
assert.strictEqual(result.size, 1);
const route = result.bestRoute();
assert(route);
assert.strictEqual(route.departureTime(), timeFromHM(8, 30));
assert.strictEqual(route.arrivalTime(), timeFromHM(8, 50));
});
it('returns empty result when no trip falls in the window', () => {
const query = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(10, 0))
.lastDepartureTime(timeFromHM(11, 0))
.build();
const result = router.rangeRoute(query);
assert.strictEqual(result.size, 0);
assert.strictEqual(result.bestRoute(), undefined);
});
it('filters runs whose arrivals exceed maxDuration for their departure slot', () => {
const tooShort = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.maxDuration(29)
.build();
assert.strictEqual(router.rangeRoute(tooShort).size, 0);
const justEnough = new RangeQuery.Builder()
.from(0)
.to(1)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.maxDuration(30)
.build();
assert.strictEqual(router.rangeRoute(justEnough).size, 2);
});
});
describe('same-stop query (origin equals destination)', () => {
// Network: two stops, one route with two trips both departing from stop 0.
// The query goes from stop 0 to stop 0, so the destination is trivially
// reachable in round 0 with zero duration for every departure slot.
// Without the trivial-destination guard, every slot would be stored as a
// Pareto-optimal run (O(trips) runs). With the fix, only one run is kept.
let router: RangeRouter;
beforeEach(() => {
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, 0),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
],
},
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
{
id: 1,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 0),
},
],
},
],
}),
];
const serviceRoutes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
const timetable = new Timetable(
stopsAdjacency,
routesAdjacency,
serviceRoutes,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'stop',
name: 'Stop',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'other',
name: 'Other',
lat: 0,
lon: 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 RangeRouter(timetable, stopsIndex, accessFinder, raptor);
});
it('produces exactly one Pareto run even when multiple departure slots fall in the window', () => {
// The window covers both trips departing from stop 0 (08:00 and 08:30),
// so collectDepartureTimes generates two slots. Without the guard each
// would be stored as Pareto-optimal (arrival = depTime for same-stop).
const query = new RangeQuery.Builder()
.from(0)
.to(0)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.build();
const result = router.rangeRoute(query);
assert.strictEqual(result.size, 1);
});
it('stores the latest-departing trivial run', () => {
const query = new RangeQuery.Builder()
.from(0)
.to(0)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.build();
const result = router.rangeRoute(query);
// ParetoRun.departureTime is the origin departure; the reconstructed
// Route has no vehicle legs so route.departureTime() is not available.
const [run] = result;
assert(run);
assert.strictEqual(run.departureTime, timeFromHM(8, 30));
});
});
describe('with no destinations (full-network / isochrone mode)', () => {
// Two-stop network, two trips:
// trip 0: stop 0 departs 08:00 → stop 1 arrives 08:30 (30-min journey)
// trip 1: stop 0 departs 08:30 → stop 1 arrives 09:00 (30-min journey)
// The query window covers both departure slots; no destination is specified.
let result: RangeResult;
beforeEach(() => {
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, 0),
},
{
id: 1,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
],
},
{
stops: [
{
id: 0,
arrivalTime: timeFromHM(8, 30),
departureTime: timeFromHM(8, 30),
},
{
id: 1,
arrivalTime: timeFromHM(9, 0),
departureTime: timeFromHM(9, 0),
},
],
},
],
}),
];
const serviceRoutes: ServiceRoute[] = [
{ type: RouteTypes.BUS, name: 'Line 1', routes: [0] },
];
const timetable = new Timetable(
stopsAdjacency,
routesAdjacency,
serviceRoutes,
);
const stops: Stop[] = [
{
id: 0,
sourceStopId: 'origin',
name: 'Origin',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
{
id: 1,
sourceStopId: 'dest',
name: 'Destination',
lat: 0,
lon: 0,
children: [],
locationType: 'SIMPLE_STOP_OR_PLATFORM',
},
];
const stopsIndex = new StopsIndex(stops);
const accessFinder = new AccessFinder(timetable, stopsIndex);
const raptor = new Raptor(timetable);
const router = new RangeRouter(
timetable,
stopsIndex,
accessFinder,
raptor,
);
// Omitting .to() leaves toValue as its default empty Set.
const query = new RangeQuery.Builder()
.from(0)
.departureTime(timeFromHM(8, 0))
.lastDepartureTime(timeFromHM(8, 30))
.build();
result = router.rangeRoute(query);
});
it('returns a run for every departure slot in the window', () => {
// Without destinations the trivialDestCovered guard (0 === 0) previously
// aborted the loop before any run was processed; now both slots are kept.
assert.strictEqual(result.size, 2);
});
it('allEarliestArrivals covers all reachable stops', () => {
const arrivals = result.allEarliestArrivals();
// Stop 1 is first reached by trip 0 (depart 08:00, arrive 08:30).
assert.strictEqual(arrivals.get(1)?.arrival, timeFromHM(8, 30));
});
it('allShortestDurations covers all reachable stops', () => {
const durations = result.allShortestDurations();
// Both trips take 30 min; shortest duration to stop 1 is 30 min.
assert.strictEqual(durations.get(1)?.duration, 30);
});
it('bestRoute without an explicit stop returns undefined', () => {
assert.strictEqual(result.bestRoute(), undefined);
});
it('getRoutes returns an empty array', () => {
assert.deepStrictEqual(result.getRoutes(), []);
});
});
});