From c1cd7d0d24201384dc28db9852a027c755e08ce5 Mon Sep 17 00:00:00 2001 From: Aubry Cholleton Date: Sun, 3 May 2026 03:03:13 +0200 Subject: [PATCH 1/2] perf: allow for max duration query limit. --- README.md | 1 + src/cli/perf.ts | 6 +- src/cli/repl.ts | 72 +++++++++++++++-------- src/router.ts | 3 + src/routing/__tests__/plainRouter.test.ts | 22 +++++++ src/routing/__tests__/rangeRouter.test.ts | 22 +++++++ src/routing/plainRouter.ts | 1 + src/routing/query.ts | 18 ++++++ src/routing/rangeRouter.ts | 2 + src/routing/rangeState.ts | 4 ++ src/routing/raptor.ts | 18 ++++-- src/routing/state.ts | 32 +++++++++- src/timetable/timetable.ts | 7 +-- 13 files changed, 173 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 4c173c7..ca4bf50 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Query options: | ----------------------- | --------- | ---------------------------------------------------------------------- | | `maxTransfers` | `5` | Maximum number of transfers | | `minTransferTime` | `2 min` | Fallback minimum transfer time | +| `maxDuration` | unlimited | Maximum total journey duration from the query departure time | | `maxInitialWaitingTime` | unlimited | Maximum wait for the first vehicle after arriving at the boarding stop | | `transportModes` | all | Restrict to a subset of GTFS route types | diff --git a/src/cli/perf.ts b/src/cli/perf.ts index 7371df0..fa2f49b 100644 --- a/src/cli/perf.ts +++ b/src/cli/perf.ts @@ -127,7 +127,8 @@ export const loadQueriesFromJson = ( .from(fromStop.id) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .to(new Set(toStops.map((stop) => stop!.id))) - .departureTime(timeFromString(serializedQuery.departureTime)); + .departureTime(timeFromString(serializedQuery.departureTime)) + .maxDuration(6 * 60); if (serializedQuery.maxTransfers !== undefined) { queryBuilder.maxTransfers(serializedQuery.maxTransfers); @@ -180,7 +181,8 @@ export const loadRangeQueriesFromJson = ( .to(new Set(toStops.map((stop) => stop!.id))) .departureTime(timeFromString(serializedQuery.departureTime)) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!)); + .lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!)) + .maxDuration(6 * 60); if (serializedQuery.maxTransfers !== undefined) { queryBuilder.maxTransfers(serializedQuery.maxTransfers); diff --git a/src/cli/repl.ts b/src/cli/repl.ts index 071fc8a..2b9c333 100644 --- a/src/cli/repl.ts +++ b/src/cli/repl.ts @@ -49,8 +49,11 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { this.displayPrompt(); }, }); + const routeSyntax = + '.route from to at [before ] [with transfers] [wait max minutes]'; + replServer.defineCommand('route', { - help: 'Find a route using .route from to at [before ] [with transfers]', + help: `Find a route using ${routeSyntax}`, action(routeQuery: string) { this.clearBufferedCommand(); const parts = routeQuery.split(' ').filter(Boolean); @@ -60,11 +63,13 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { const atIndex = parts.indexOf('at'); const beforeIndex = parts.indexOf('before'); const withIndex = parts.indexOf('with'); + const waitIndex = parts.indexOf('wait'); + const routeClauseIndexes = [beforeIndex, withIndex, waitIndex].filter( + (index) => index !== -1, + ); if (fromIndex === -1 || toIndex === -1 || atIndex === -1) { - console.log( - 'Usage: .route from to at [before ] [with transfers]', - ); + console.log(`Usage: ${routeSyntax}`); this.displayPrompt(); return; } @@ -72,17 +77,20 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { const fromId = parts.slice(fromIndex + 1, toIndex).join(' '); const toId = parts.slice(toIndex + 1, atIndex).join(' '); - // atTime ends at 'before', 'with', or the end of the input. - const atTimeEnd = - beforeIndex !== -1 - ? beforeIndex - : withIndex !== -1 - ? withIndex - : parts.length; + // atTime ends at 'before', 'with', 'wait', or the end of the input. + const atTimeEnd = Math.min( + ...routeClauseIndexes.filter((index) => index > atIndex), + parts.length, + ); const atTime = parts.slice(atIndex + 1, atTimeEnd).join(' '); // beforeTime is only present when the 'before' keyword appears. - const beforeTimeEnd = withIndex !== -1 ? withIndex : parts.length; + const beforeTimeEnd = Math.min( + ...[withIndex, waitIndex].filter( + (index) => index !== -1 && index > beforeIndex, + ), + parts.length, + ); const beforeTime = beforeIndex !== -1 ? parts.slice(beforeIndex + 1, beforeTimeEnd).join(' ') @@ -92,11 +100,23 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { withIndex !== -1 && parts[withIndex + 1] !== undefined ? parseInt(parts[withIndex + 1] as string) : 4; - - if (!fromId || !toId || !atTime) { - console.log( - 'Usage: .route from to at [before ] [with transfers]', - ); + const maxInitialWaitingTime = + waitIndex !== -1 && + parts[waitIndex + 1] === 'max' && + parts[waitIndex + 2] !== undefined + ? Number(parts[waitIndex + 2]) + : undefined; + const waitUnit = parts[waitIndex + 3]; + const hasInvalidWaitClause = + waitIndex !== -1 && + (parts[waitIndex + 1] !== 'max' || + maxInitialWaitingTime === undefined || + !Number.isFinite(maxInitialWaitingTime) || + maxInitialWaitingTime < 0 || + (waitUnit !== 'minute' && waitUnit !== 'minutes')); + + if (!fromId || !toId || !atTime || hasInvalidWaitClause) { + console.log(`Usage: ${routeSyntax}`); this.displayPrompt(); return; } @@ -131,13 +151,16 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { const router = new Router(timetable, stopsIndex); if (beforeTime !== undefined) { const lastDepartureTime = timeFromString(beforeTime); - const query = new RangeQuery.Builder() + const queryBuilder = new RangeQuery.Builder() .from(fromStop.id) .to(toStop.id) .departureTime(departureTime) .lastDepartureTime(lastDepartureTime) - .maxTransfers(maxTransfers) - .build(); + .maxTransfers(maxTransfers); + if (maxInitialWaitingTime !== undefined) { + queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime); + } + const query = queryBuilder.build(); const result = router.rangeRoute(query); @@ -160,12 +183,15 @@ export const startRepl = (stopsPath: string, timetablePath: string) => { }); } } else { - const query = new Query.Builder() + const queryBuilder = new Query.Builder() .from(fromStop.id) .to(toStop.id) .departureTime(departureTime) - .maxTransfers(maxTransfers) - .build(); + .maxTransfers(maxTransfers); + if (maxInitialWaitingTime !== undefined) { + queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime); + } + const query = queryBuilder.build(); const result = router.route(query); const arrivalTime = result.arrivalAt(toStop.id); diff --git a/src/router.ts b/src/router.ts index 45839e5..dc0b2d4 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,5 @@ import { Plotter } from './routing/plotter.js'; +import type { QueryOptions, RangeQueryOptions } from './routing/query.js'; import { Query, RangeQuery } from './routing/query.js'; import { Result } from './routing/result.js'; import type { Leg, Transfer, VehicleLeg } from './routing/route.js'; @@ -40,6 +41,8 @@ export type { Leg, LocationType, ParetoRun, + QueryOptions, + RangeQueryOptions, RouteType, ServiceRouteInfo, SourceStopId, diff --git a/src/routing/__tests__/plainRouter.test.ts b/src/routing/__tests__/plainRouter.test.ts index 1b803a5..62197d3 100644 --- a/src/routing/__tests__/plainRouter.test.ts +++ b/src/routing/__tests__/plainRouter.test.ts @@ -148,6 +148,28 @@ describe('PlainRouter', () => { // 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', () => { diff --git a/src/routing/__tests__/rangeRouter.test.ts b/src/routing/__tests__/rangeRouter.test.ts index 8a1dfbe..aa4d13c 100644 --- a/src/routing/__tests__/rangeRouter.test.ts +++ b/src/routing/__tests__/rangeRouter.test.ts @@ -347,6 +347,28 @@ describe('RangeRouter', () => { 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)', () => { diff --git a/src/routing/plainRouter.ts b/src/routing/plainRouter.ts index 876575b..4bdc7b6 100644 --- a/src/routing/plainRouter.ts +++ b/src/routing/plainRouter.ts @@ -47,6 +47,7 @@ export class PlainRouter { accessLegs, this.timetable.nbStops(), query.options.maxTransfers + 1, + query.options.maxDuration, ); this.raptor.run(query.options, routingState); diff --git a/src/routing/query.ts b/src/routing/query.ts index 82039d3..8dbdc89 100644 --- a/src/routing/query.ts +++ b/src/routing/query.ts @@ -6,6 +6,14 @@ export type QueryOptions = { maxTransfers: number; minTransferTime: Duration; transportModes: Set; + /** + * Maximum total journey duration (in minutes) from the query departure time. + * + * When set, arrivals after `departureTime + maxDuration` are skipped. The + * duration includes initial access, waiting time, transit legs, and transfers. + * Undefined means no limit. + */ + maxDuration?: Duration; /** * Maximum time (in minutes) the traveler is willing to wait at the first * boarding stop before the first transit vehicle departs. @@ -98,6 +106,16 @@ export class Query { return this; } + /** + * Sets the maximum total journey duration (in minutes) from the query + * departure time. The limit includes initial access, waiting time, transit + * legs, and transfers. + */ + maxDuration(maxDuration: Duration): this { + this.optionsValue.maxDuration = maxDuration; + return this; + } + /** * Sets the maximum time (in minutes) the traveler is willing to wait at * the first boarding stop before the first transit vehicle departs. diff --git a/src/routing/rangeRouter.ts b/src/routing/rangeRouter.ts index 4038ac7..099554b 100644 --- a/src/routing/rangeRouter.ts +++ b/src/routing/rangeRouter.ts @@ -92,6 +92,7 @@ export class RangeRouter { accessLegs, this.timetable.nbStops(), maxRounds, + query.options.maxDuration, ); rangeState.setCurrentRun(routingState); this.raptor.run( @@ -122,6 +123,7 @@ export class RangeRouter { legs, this.timetable.nbStops(), maxRounds, + query.options.maxDuration, ); } else { routingState.resetFor(depTime, legs); diff --git a/src/routing/rangeState.ts b/src/routing/rangeState.ts index 56aa36b..106b322 100644 --- a/src/routing/rangeState.ts +++ b/src/routing/rangeState.ts @@ -109,6 +109,10 @@ export class RangeRaptorState implements IRaptorState { return this._destinationBest; } + get maxArrivalTime(): Time { + return this.currentRun.maxArrivalTime; + } + isDestination(stop: StopId): boolean { return this.currentRun.isDestination(stop); } diff --git a/src/routing/raptor.ts b/src/routing/raptor.ts index 830ad2f..738f944 100644 --- a/src/routing/raptor.ts +++ b/src/routing/raptor.ts @@ -34,6 +34,9 @@ export interface IRaptorState { */ readonly destinationBest: Time; + /** Latest arrival time allowed by the current query/run. */ + readonly maxArrivalTime: Time; + /** Returns `true` if `stop` is one of the query's destination stops. */ isDestination(stop: StopId): boolean; @@ -212,6 +215,7 @@ export class Raptor { if ( dropOffType !== NOT_AVAILABLE && + arrivalTime <= state.maxArrivalTime && arrivalTime < state.improvementBound(round, currentStop) && arrivalTime < state.destinationBest ) { @@ -282,6 +286,7 @@ export class Raptor { if ( dropOffType !== NOT_AVAILABLE && + arrivalTime <= state.maxArrivalTime && arrivalTime < state.improvementBound(round, currentStop) && arrivalTime < state.destinationBest ) { @@ -334,17 +339,21 @@ export class Raptor { ); if (firstBoardableTrip !== undefined) { + const departureTime = route.departureFrom( + currentStopIndex, + firstBoardableTrip, + ); // At round 1, enforce maxInitialWaitingTime: skip boarding if the // traveler would have to wait longer than the allowed threshold at // the first boarding stop. const exceedsInitialWait = round === 1 && options.maxInitialWaitingTime !== undefined && - route.departureFrom(currentStopIndex, firstBoardableTrip) - - earliestArrivalOnPreviousRound > + departureTime - earliestArrivalOnPreviousRound > options.maxInitialWaitingTime; + const exceedsMaxDuration = departureTime > state.maxArrivalTime; - if (!exceedsInitialWait) { + if (!exceedsInitialWait && !exceedsMaxDuration) { activeTripIndex = firstBoardableTrip; activeTripBoardStopIndex = currentStopIndex; activeTripStopOffset = route.tripStopOffset(firstBoardableTrip); @@ -391,6 +400,7 @@ export class Raptor { const arrivalAfterTransfer = currentArrival.arrival + transferTime; if ( + arrivalAfterTransfer <= state.maxArrivalTime && arrivalAfterTransfer < state.improvementBound(round, transfer.destination) && arrivalAfterTransfer < state.destinationBest @@ -398,7 +408,7 @@ export class Raptor { arrivalsAtCurrentRound[transfer.destination] = { arrival: arrivalAfterTransfer, from: stop, - to: transfer.destination, + to: transfer.destination, // TODO needed? minTransferTime: transferTime || undefined, type: transfer.type, } as TransferEdge; diff --git a/src/routing/state.ts b/src/routing/state.ts index 55b1072..434fb5e 100644 --- a/src/routing/state.ts +++ b/src/routing/state.ts @@ -33,7 +33,7 @@ export type VehicleEdge = TripStop & { export type TransferEdge = { arrival: Time; from: StopId; - to: StopId; + to: StopId; // TODO remove type: TransferType; minTransferTime?: Duration; }; @@ -61,7 +61,16 @@ export class RoutingState implements IRaptorState { * Indexed as graph[round][stopId]. Entries are undefined for stops not * reached in that particular round. */ + // TODO do not expose readonly graph: (RoutingEdge | undefined)[][]; + // TODO Can use typed arrays to represent the graph + // Uint32 [(alightStopId -> ? =index/to), boardingStopId (stopIndex,from), + // RouteId/TransferId, TripId (if not transfer), (previous_round, previous_stop + // -> allows to reconstruct transfers incl. continuous] + // TODO should try to reuse them in range raptor and use only one init + + // TODO take out arrival times from Graph + // private arrivalTimes: Uint16Array[]; /** * Earliest arrival time at each stop (minutes from midnight), indexed by stop ID. @@ -89,6 +98,18 @@ export class RoutingState implements IRaptorState { */ private _destinationBest: Time = UNREACHED_TIME; + /** + * Maximum arrival time allowed for this run. Defaults to UNREACHED_TIME when + * the query has no maxDuration limit. + */ + maxArrivalTime: Time = UNREACHED_TIME; + + /** + * Query-level maximum duration, retained so resetFor() can recompute the + * absolute max arrival time for each departure-time iteration. + */ + private readonly maxDuration?: Duration; + /** * Every stop that has received an arrival improvement during the current run, * in the order the improvements occurred. Used by {@link resetFor} to clear @@ -102,8 +123,12 @@ export class RoutingState implements IRaptorState { accessPaths: AccessPoint[], nbStops: number, maxRounds: number = 0, + maxDuration?: Duration, ) { this.destinations = destinations; + this.maxDuration = maxDuration; + this.maxArrivalTime = + maxDuration === undefined ? UNREACHED_TIME : departureTime + maxDuration; this.destinationSet = new Set(destinations); this.earliestArrivalTimes = new Uint16Array(nbStops).fill(UNREACHED_TIME); this.earliestArrivalLegs = new Uint8Array(nbStops); @@ -126,6 +151,7 @@ export class RoutingState implements IRaptorState { const seededOrigins = new Set(); for (const access of accessPaths) { const arrival = depTime + access.duration; + if (arrival > this.maxArrivalTime) continue; const edge: OriginNode | AccessEdge = access.duration === 0 ? { stopId: access.fromStopId, arrival: depTime } @@ -224,6 +250,10 @@ export class RoutingState implements IRaptorState { } this.reachedStops.length = 0; this._destinationBest = UNREACHED_TIME; + this.maxArrivalTime = + this.maxDuration === undefined + ? UNREACHED_TIME + : depTime + this.maxDuration; this.seedAccessPaths(depTime, accessPaths); } diff --git a/src/timetable/timetable.ts b/src/timetable/timetable.ts index 47a4d4d..fb9599d 100644 --- a/src/timetable/timetable.ts +++ b/src/timetable/timetable.ts @@ -18,11 +18,8 @@ import { Route, RouteId, StopRouteIndex, TripRouteIndex } from './route.js'; import { Duration, DURATION_ZERO, Time, TIME_ORIGIN } from './time.js'; import { encode, TripStopId } from './tripStopId.js'; -export type TransferType = - | 'RECOMMENDED' - | 'GUARANTEED' - | 'REQUIRES_MINIMAL_TIME' - | 'IN_SEAT'; +export type TransferType = // TODO use number to represent that. + 'RECOMMENDED' | 'GUARANTEED' | 'REQUIRES_MINIMAL_TIME' | 'IN_SEAT'; export type Transfer = { destination: StopId; From 180179e96dff7536c7d9b9d87c02eebe2afb7ba8 Mon Sep 17 00:00:00 2001 From: Aubry Cholleton Date: Sun, 3 May 2026 13:08:51 +0200 Subject: [PATCH 2/2] add tests. --- src/routing/__tests__/raptor.test.ts | 142 +++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/routing/__tests__/raptor.test.ts b/src/routing/__tests__/raptor.test.ts index 43dec50..35272d3 100644 --- a/src/routing/__tests__/raptor.test.ts +++ b/src/routing/__tests__/raptor.test.ts @@ -249,6 +249,148 @@ describe('Raptor', () => { }); }); + 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,