UNPKG

minotor

Version:

A lightweight client-side transit routing library.

628 lines (561 loc) 18.3 kB
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(), []); }); }); });