minotor 11.1.2 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.cspell.json +7 -1
  2. package/CHANGELOG.md +3 -3
  3. package/README.md +111 -86
  4. package/dist/cli/perf.d.ts +57 -18
  5. package/dist/cli.mjs +1371 -342
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/parser.cjs.js +57 -4
  8. package/dist/parser.cjs.js.map +1 -1
  9. package/dist/parser.esm.js +57 -4
  10. package/dist/parser.esm.js.map +1 -1
  11. package/dist/router.cjs.js +1 -1
  12. package/dist/router.cjs.js.map +1 -1
  13. package/dist/router.d.ts +5 -5
  14. package/dist/router.esm.js +1 -1
  15. package/dist/router.esm.js.map +1 -1
  16. package/dist/router.umd.js +1 -1
  17. package/dist/router.umd.js.map +1 -1
  18. package/dist/routing/__tests__/access.test.d.ts +1 -0
  19. package/dist/routing/__tests__/plainRouter.test.d.ts +1 -0
  20. package/dist/routing/__tests__/rangeResult.test.d.ts +1 -0
  21. package/dist/routing/__tests__/rangeRouter.test.d.ts +1 -0
  22. package/dist/routing/__tests__/rangeState.test.d.ts +1 -0
  23. package/dist/routing/__tests__/raptor.test.d.ts +1 -0
  24. package/dist/routing/__tests__/state.test.d.ts +1 -0
  25. package/dist/routing/access.d.ts +55 -0
  26. package/dist/routing/plainRouter.d.ts +21 -0
  27. package/dist/routing/plotter.d.ts +9 -0
  28. package/dist/routing/query.d.ts +132 -13
  29. package/dist/routing/rangeResult.d.ts +155 -0
  30. package/dist/routing/rangeRouter.d.ts +24 -0
  31. package/dist/routing/rangeState.d.ts +83 -0
  32. package/dist/routing/raptor.d.ts +96 -0
  33. package/dist/routing/result.d.ts +27 -7
  34. package/dist/routing/route.d.ts +5 -21
  35. package/dist/routing/router.d.ts +20 -91
  36. package/dist/routing/state.d.ts +92 -17
  37. package/dist/timetable/route.d.ts +8 -0
  38. package/dist/timetable/timetable.d.ts +17 -1
  39. package/package.json +1 -1
  40. package/src/__e2e__/benchmark.json +18 -0
  41. package/src/__e2e__/router.test.ts +461 -127
  42. package/src/cli/minotor.ts +39 -3
  43. package/src/cli/perf.ts +324 -60
  44. package/src/cli/repl.ts +96 -41
  45. package/src/router.ts +11 -3
  46. package/src/routing/__tests__/access.test.ts +294 -0
  47. package/src/routing/__tests__/plainRouter.test.ts +1633 -0
  48. package/src/routing/__tests__/plotter.test.ts +8 -8
  49. package/src/routing/__tests__/rangeResult.test.ts +273 -0
  50. package/src/routing/__tests__/rangeRouter.test.ts +472 -0
  51. package/src/routing/__tests__/rangeState.test.ts +246 -0
  52. package/src/routing/__tests__/raptor.test.ts +366 -0
  53. package/src/routing/__tests__/result.test.ts +27 -27
  54. package/src/routing/__tests__/route.test.ts +28 -0
  55. package/src/routing/__tests__/router.test.ts +75 -1587
  56. package/src/routing/__tests__/state.test.ts +78 -0
  57. package/src/routing/access.ts +144 -0
  58. package/src/routing/plainRouter.ts +60 -0
  59. package/src/routing/plotter.ts +53 -6
  60. package/src/routing/query.ts +116 -13
  61. package/src/routing/rangeResult.ts +292 -0
  62. package/src/routing/rangeRouter.ts +167 -0
  63. package/src/routing/rangeState.ts +150 -0
  64. package/src/routing/raptor.ts +416 -0
  65. package/src/routing/result.ts +68 -26
  66. package/src/routing/route.ts +15 -53
  67. package/src/routing/router.ts +40 -480
  68. package/src/routing/state.ts +191 -32
  69. package/src/timetable/__tests__/timetable.test.ts +373 -0
  70. package/src/timetable/route.ts +16 -4
  71. package/src/timetable/timetable.ts +54 -1
@@ -3,6 +3,8 @@ import { StopId } from '../stops/stops.js';
3
3
  import { StopRouteIndex } from '../timetable/route.js';
4
4
  import { Duration, Time } from '../timetable/time.js';
5
5
  import { TransferType, TripStop } from '../timetable/timetable.js';
6
+ import { AccessPoint } from './access.js';
7
+ import type { IRaptorState } from './raptor.js';
6
8
 
7
9
  /**
8
10
  * Sentinel value used in the internal arrival-time array to mark stops not yet reached.
@@ -10,14 +12,20 @@ import { TransferType, TripStop } from '../timetable/timetable.js';
10
12
  */
11
13
  export const UNREACHED_TIME: Time = 0xffff;
12
14
 
13
- /** An origin stop reached at the query departure time, before any transit leg. */
14
- export type OriginNode = { arrival: Time };
15
+ export type OriginNode = { stopId: StopId; arrival: Time };
16
+
17
+ export type AccessEdge = {
18
+ arrival: Time;
19
+ from: StopId;
20
+ to: StopId;
21
+ duration: Duration;
22
+ };
15
23
 
16
24
  /** A boarded transit trip that carries the passenger from one stop to another. */
17
25
  export type VehicleEdge = TripStop & {
18
26
  arrival: Time;
19
27
  hopOffStopIndex: StopRouteIndex;
20
- /** Set when this edge continues directly from another trip (in-seat transfer). */
28
+ /** modeling in-seat transfer */
21
29
  continuationOf?: VehicleEdge;
22
30
  };
23
31
 
@@ -30,7 +38,7 @@ export type TransferEdge = {
30
38
  minTransferTime?: Duration;
31
39
  };
32
40
 
33
- export type RoutingEdge = OriginNode | VehicleEdge | TransferEdge;
41
+ export type RoutingEdge = OriginNode | AccessEdge | VehicleEdge | TransferEdge;
34
42
 
35
43
  /** The earliest arrival at a stop together with how many legs were needed to reach it. */
36
44
  export type Arrival = {
@@ -41,9 +49,9 @@ export type Arrival = {
41
49
  /**
42
50
  * Encapsulates all mutable state for a single RAPTOR routing query.
43
51
  */
44
- export class RoutingState {
52
+ export class RoutingState implements IRaptorState {
45
53
  /** Origin stop IDs for this query. */
46
- readonly origins: StopId[];
54
+ origins: StopId[];
47
55
 
48
56
  /** Destination stop IDs for this query. */
49
57
  readonly destinations: StopId[];
@@ -70,38 +78,78 @@ export class RoutingState {
70
78
  private earliestArrivalLegs: Uint8Array;
71
79
 
72
80
  /**
73
- * Initializes the routing state for a fresh query.
74
- *
75
- * All stops start as unreached. Each origin is immediately recorded at the
76
- * departure time with leg number 0, and a corresponding OriginNode is placed
77
- * in round 0 of the graph.
78
- *
79
- * @param origins Stop IDs to depart from (may be several equivalent stops).
80
- * @param destinations Stop IDs that count as the target of the query.
81
- * @param departureTime Earliest departure time in minutes from midnight.
82
- * @param nbStops Total number of stops in the timetable (sets array sizes).
81
+ * Fast O(1) membership test for destination stops.
82
+ * Built once at construction time from the `destinations` array.
83
83
  */
84
+ private readonly destinationSet: Set<StopId>;
85
+
86
+ /**
87
+ * Cached best arrival time at any destination stop, kept up-to-date by
88
+ * {@link updateArrival} so that destination pruning is always O(1).
89
+ */
90
+ private _destinationBest: Time = UNREACHED_TIME;
91
+
92
+ /**
93
+ * Every stop that has received an arrival improvement during the current run,
94
+ * in the order the improvements occurred. Used by {@link resetFor} to clear
95
+ * only the touched entries instead of scanning the entire array.
96
+ */
97
+ private readonly reachedStops: StopId[] = [];
98
+
84
99
  constructor(
85
- origins: StopId[],
86
- destinations: StopId[],
87
100
  departureTime: Time,
101
+ destinations: StopId[],
102
+ accessPaths: AccessPoint[],
88
103
  nbStops: number,
104
+ maxRounds: number = 0,
89
105
  ) {
90
- this.origins = origins;
91
106
  this.destinations = destinations;
92
-
93
- const earliestArrivalTimes = new Uint16Array(nbStops).fill(UNREACHED_TIME);
94
- const earliestArrivalLegs = new Uint8Array(nbStops); // zero-initialized = leg 0
95
- const graph0 = new Array<RoutingEdge | undefined>(nbStops);
96
-
97
- for (const stop of origins) {
98
- earliestArrivalTimes[stop] = departureTime;
99
- graph0[stop] = { arrival: departureTime };
107
+ this.destinationSet = new Set(destinations);
108
+ this.earliestArrivalTimes = new Uint16Array(nbStops).fill(UNREACHED_TIME);
109
+ this.earliestArrivalLegs = new Uint8Array(nbStops);
110
+ this.origins = []; // overwritten by seedAccessPaths below
111
+ this.graph = [new Array<RoutingEdge | undefined>(nbStops)];
112
+ for (let r = 1; r <= maxRounds; r++) {
113
+ this.graph.push(new Array<RoutingEdge | undefined>(nbStops));
100
114
  }
115
+ this.seedAccessPaths(departureTime, accessPaths);
116
+ }
101
117
 
102
- this.earliestArrivalTimes = earliestArrivalTimes;
103
- this.earliestArrivalLegs = earliestArrivalLegs;
104
- this.graph = [graph0];
118
+ /**
119
+ * Seeds round-0 arrivals and {@link origins} from a set of access paths.
120
+ * Called by the constructor and by {@link resetFor}.
121
+ * Assumes {@link earliestArrivalTimes} and {@link graph}[0] are already
122
+ * allocated and in their "cleared" state (all entries at UNREACHED_TIME /
123
+ * undefined) before this method runs.
124
+ */
125
+ private seedAccessPaths(depTime: Time, accessPaths: AccessPoint[]): void {
126
+ const seededOrigins = new Set<StopId>();
127
+ for (const access of accessPaths) {
128
+ const arrival = depTime + access.duration;
129
+ const edge: OriginNode | AccessEdge =
130
+ access.duration === 0
131
+ ? { stopId: access.fromStopId, arrival: depTime }
132
+ : {
133
+ arrival,
134
+ from: access.fromStopId,
135
+ to: access.toStopId,
136
+ duration: access.duration,
137
+ };
138
+ const stop = access.toStopId;
139
+ if (arrival < this.earliestArrivalTimes[stop]!) {
140
+ this.earliestArrivalTimes[stop] = arrival;
141
+ this.graph[0]![stop] = edge;
142
+ }
143
+ seededOrigins.add(stop);
144
+ }
145
+ for (const stop of seededOrigins) {
146
+ this.reachedStops.push(stop);
147
+ }
148
+ this.origins = Array.from(seededOrigins);
149
+ for (let i = 0; i < this.destinations.length; i++) {
150
+ const t = this.earliestArrivalTimes[this.destinations[i]!]!;
151
+ if (t < this._destinationBest) this._destinationBest = t;
152
+ }
105
153
  }
106
154
 
107
155
  /** Total number of stops in the timetable */
@@ -118,16 +166,101 @@ export class RoutingState {
118
166
  }
119
167
 
120
168
  /**
121
- * Records a new earliest arrival at a stop.
169
+ * Earliest arrival at any destination stop; {@link UNREACHED_TIME} if none
170
+ * has been reached yet. Updated automatically by {@link updateArrival}. O(1).
171
+ */
172
+ get destinationBest(): Time {
173
+ return this._destinationBest;
174
+ }
175
+
176
+ /**
177
+ * In standard RAPTOR the improvement bound is simply the per-run earliest
178
+ * arrival; the `round` argument is ignored.
179
+ */
180
+ improvementBound(_round: number, stop: StopId): Time {
181
+ return this.arrivalTime(stop);
182
+ }
122
183
 
184
+ /** No-op in standard RAPTOR — there are no shared cross-run labels to propagate. */
185
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
186
+ initRound(_round: number): void {}
187
+
188
+ /**
189
+ * Records a new earliest arrival at a stop.
123
190
  *
124
191
  * @param stop The stop that was reached.
125
192
  * @param time The arrival time in minutes from midnight.
126
193
  * @param leg The round number (number of transit legs taken so far).
127
194
  */
128
195
  updateArrival(stop: StopId, time: Time, leg: number): void {
196
+ this.reachedStops.push(stop);
129
197
  this.earliestArrivalTimes[stop] = time;
130
198
  this.earliestArrivalLegs[stop] = leg;
199
+ if (this.destinationSet.has(stop) && time < this._destinationBest) {
200
+ this._destinationBest = time;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resets this state for a new departure-time iteration **without
206
+ * reallocating** the underlying arrays.
207
+ *
208
+ * Only the stops recorded in {@link reachedStops} are touched — all other
209
+ * entries are already at their initial bound values.
210
+ *
211
+ * After this call the state is equivalent to a freshly constructed
212
+ * {@link RoutingState} for the given `depTime` and `accessPaths`.
213
+ *
214
+ * @param depTime New origin departure time.
215
+ * @param accessPaths Access legs for this departure-time slot.
216
+ */
217
+ resetFor(depTime: Time, accessPaths: AccessPoint[]): void {
218
+ for (const stop of this.reachedStops) {
219
+ this.earliestArrivalTimes[stop] = UNREACHED_TIME;
220
+ this.earliestArrivalLegs[stop] = 0;
221
+ for (let r = 0; r < this.graph.length; r++) {
222
+ this.graph[r]![stop] = undefined;
223
+ }
224
+ }
225
+ this.reachedStops.length = 0;
226
+ this._destinationBest = UNREACHED_TIME;
227
+ this.seedAccessPaths(depTime, accessPaths);
228
+ }
229
+
230
+ /**
231
+ * Iterates over every stop that has been reached, yielding its stop ID,
232
+ * earliest arrival time, and the number of legs taken to reach it.
233
+ *
234
+ * Unreached stops (those still at UNREACHED_TIME) are skipped entirely.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * for (const { stop, arrival, legNumber } of routingState.arrivals()) {
239
+ * console.log(`Stop ${stop}: arrived at ${arrival} after ${legNumber} leg(s)`);
240
+ * }
241
+ * ```
242
+ */
243
+ *arrivals(): Generator<{ stop: StopId; arrival: Time; legNumber: number }> {
244
+ for (let stop = 0; stop < this.earliestArrivalTimes.length; stop++) {
245
+ const time = this.earliestArrivalTimes[stop]!;
246
+ if (time < UNREACHED_TIME) {
247
+ yield {
248
+ stop,
249
+ arrival: time,
250
+ legNumber: this.earliestArrivalLegs[stop]!,
251
+ };
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Finds the earliest arrival time at any stop from a given set of destinations.
258
+ *
259
+ * @param routingState The routing state containing arrival times and destinations.
260
+ * @returns The earliest arrival time among the provided destinations.
261
+ */
262
+ earliestArrivalAtAnyDestination(): Time {
263
+ return this._destinationBest;
131
264
  }
132
265
 
133
266
  /**
@@ -140,6 +273,14 @@ export class RoutingState {
140
273
  return { arrival: time, legNumber: this.earliestArrivalLegs[stop]! };
141
274
  }
142
275
 
276
+ /**
277
+ * Returns `true` if `stop` is one of the query's destination stops.
278
+ * O(1) — backed by a `Set` built at construction time.
279
+ */
280
+ isDestination(stop: StopId): boolean {
281
+ return this.destinationSet.has(stop);
282
+ }
283
+
143
284
  /**
144
285
  * Creates a {@link RoutingState} from fully-specified raw data.
145
286
  *
@@ -170,7 +311,16 @@ export class RoutingState {
170
311
  arrivals?: [stop: StopId, time: Time, leg: number][];
171
312
  graph?: [stop: StopId, edge: RoutingEdge][][];
172
313
  }): RoutingState {
173
- const state = new RoutingState(origins, destinations, 0, nbStops);
314
+ const state = new RoutingState(
315
+ 0,
316
+ destinations,
317
+ origins.map((stop) => ({
318
+ fromStopId: stop,
319
+ toStopId: stop,
320
+ duration: 0,
321
+ })),
322
+ nbStops,
323
+ );
174
324
 
175
325
  // Replace the arrival arrays with freshly built ones so the constructor's
176
326
  // origin-seeding doesn't bleed into the test state.
@@ -183,6 +333,15 @@ export class RoutingState {
183
333
  state.earliestArrivalTimes = earliestArrivalTimes;
184
334
  state.earliestArrivalLegs = earliestArrivalLegs;
185
335
 
336
+ // Recompute _destinationBest from the test data since we bypassed updateArrival.
337
+ // fromTestData is a static method of RoutingState, so private access is allowed.
338
+ state._destinationBest = UNREACHED_TIME;
339
+ for (const dest of destinations) {
340
+ const t = earliestArrivalTimes[dest];
341
+ if (t !== undefined && t < state._destinationBest)
342
+ state._destinationBest = t;
343
+ }
344
+
186
345
  // Convert the sparse per-round representation to dense arrays and replace
187
346
  // the graph in-place.
188
347
  const denseRounds = graph.map((round) => {
@@ -9,6 +9,7 @@ import {
9
9
  StopAdjacency,
10
10
  Timetable,
11
11
  TripStop,
12
+ TripTransfers,
12
13
  } from '../timetable.js';
13
14
  import { encode } from '../tripStopId.js';
14
15
 
@@ -655,5 +656,377 @@ describe('Timetable', () => {
655
656
  );
656
657
  });
657
658
  });
659
+
660
+ describe('findFirstBoardableTrip', () => {
661
+ const BOARDING_STOP_INDEX = 0;
662
+ const FFBT_ROUTE_ID = 2;
663
+
664
+ const ffbtRoute = Route.of({
665
+ id: FFBT_ROUTE_ID,
666
+ serviceRouteId: 2,
667
+ trips: [
668
+ {
669
+ stops: [
670
+ {
671
+ id: 0,
672
+ arrivalTime: timeFromHMS(8, 0, 0),
673
+ departureTime: timeFromHMS(8, 0, 0),
674
+ },
675
+ {
676
+ id: 1,
677
+ arrivalTime: timeFromHMS(8, 30, 0),
678
+ departureTime: timeFromHMS(8, 30, 0),
679
+ },
680
+ ],
681
+ },
682
+ {
683
+ stops: [
684
+ {
685
+ id: 0,
686
+ arrivalTime: timeFromHMS(9, 0, 0),
687
+ departureTime: timeFromHMS(9, 0, 0),
688
+ pickUpType: NOT_AVAILABLE,
689
+ },
690
+ {
691
+ id: 1,
692
+ arrivalTime: timeFromHMS(9, 30, 0),
693
+ departureTime: timeFromHMS(9, 30, 0),
694
+ },
695
+ ],
696
+ },
697
+ {
698
+ stops: [
699
+ {
700
+ id: 0,
701
+ arrivalTime: timeFromHMS(10, 0, 0),
702
+ departureTime: timeFromHMS(10, 0, 0),
703
+ },
704
+ {
705
+ id: 1,
706
+ arrivalTime: timeFromHMS(10, 30, 0),
707
+ departureTime: timeFromHMS(10, 30, 0),
708
+ },
709
+ ],
710
+ },
711
+ {
712
+ stops: [
713
+ {
714
+ id: 0,
715
+ arrivalTime: timeFromHMS(11, 0, 0),
716
+ departureTime: timeFromHMS(11, 0, 0),
717
+ },
718
+ {
719
+ id: 1,
720
+ arrivalTime: timeFromHMS(11, 30, 0),
721
+ departureTime: timeFromHMS(11, 30, 0),
722
+ },
723
+ ],
724
+ },
725
+ ],
726
+ });
727
+
728
+ const ffbtStopsAdjacency: StopAdjacency[] = [
729
+ { routes: [FFBT_ROUTE_ID] },
730
+ { routes: [FFBT_ROUTE_ID] },
731
+ ];
732
+
733
+ const ffbtServiceRoutes = [
734
+ { type: 'BUS' as const, name: 'Route 2', routes: [FFBT_ROUTE_ID] },
735
+ ];
736
+
737
+ const ffbtTimetable = new Timetable(
738
+ ffbtStopsAdjacency,
739
+ [ffbtRoute],
740
+ ffbtServiceRoutes,
741
+ );
742
+
743
+ describe('without fromTripStop', () => {
744
+ it('returns the first trip with an available pickup', () => {
745
+ const result = ffbtTimetable.findFirstBoardableTrip(
746
+ BOARDING_STOP_INDEX,
747
+ ffbtRoute,
748
+ 0,
749
+ );
750
+ assert.strictEqual(result, 0);
751
+ });
752
+
753
+ it('skips trips with NOT_AVAILABLE pickup', () => {
754
+ // Start scanning from trip 1 (NOT_AVAILABLE) → must return trip 2.
755
+ const result = ffbtTimetable.findFirstBoardableTrip(
756
+ BOARDING_STOP_INDEX,
757
+ ffbtRoute,
758
+ 1,
759
+ );
760
+ assert.strictEqual(result, 2);
761
+ });
762
+
763
+ it('starts scanning at earliestTrip', () => {
764
+ // Even though trip 0 is valid, scanning starts at trip 2.
765
+ const result = ffbtTimetable.findFirstBoardableTrip(
766
+ BOARDING_STOP_INDEX,
767
+ ffbtRoute,
768
+ 2,
769
+ );
770
+ assert.strictEqual(result, 2);
771
+ });
772
+
773
+ it('respects the beforeTrip exclusive upper bound', () => {
774
+ // beforeTrip=1 means only trip 0 is in scope.
775
+ const result = ffbtTimetable.findFirstBoardableTrip(
776
+ BOARDING_STOP_INDEX,
777
+ ffbtRoute,
778
+ 0,
779
+ 0,
780
+ 1,
781
+ );
782
+ assert.strictEqual(result, 0);
783
+ });
784
+
785
+ it('returns undefined when the only trip in range has a NOT_AVAILABLE pickup', () => {
786
+ // [1, 2) contains only trip 1, which is NOT_AVAILABLE.
787
+ const result = ffbtTimetable.findFirstBoardableTrip(
788
+ BOARDING_STOP_INDEX,
789
+ ffbtRoute,
790
+ 1,
791
+ 0,
792
+ 2,
793
+ );
794
+ assert.strictEqual(result, undefined);
795
+ });
796
+
797
+ it('returns undefined when earliestTrip is past all trips', () => {
798
+ const result = ffbtTimetable.findFirstBoardableTrip(
799
+ BOARDING_STOP_INDEX,
800
+ ffbtRoute,
801
+ 99,
802
+ );
803
+ assert.strictEqual(result, undefined);
804
+ });
805
+
806
+ it('returns undefined when beforeTrip equals earliestTrip (empty range)', () => {
807
+ const result = ffbtTimetable.findFirstBoardableTrip(
808
+ BOARDING_STOP_INDEX,
809
+ ffbtRoute,
810
+ 2,
811
+ 0,
812
+ 2,
813
+ );
814
+ assert.strictEqual(result, undefined);
815
+ });
816
+ });
817
+
818
+ describe('with fromTripStop', () => {
819
+ const fromTripStop: TripStop = {
820
+ routeId: 99,
821
+ stopIndex: 0,
822
+ tripIndex: 0,
823
+ };
824
+
825
+ it('returns the first trip whose departure satisfies after + transferTime', () => {
826
+ // after=09:30, transferTime=30 → requiredTime=10:00.
827
+ // trip 0 (08:00) < 10:00 → skip.
828
+ // trip 1 (09:00) NOT_AVAILABLE → skip.
829
+ // trip 2 (10:00) ≥ 10:00 → returned.
830
+ const result = ffbtTimetable.findFirstBoardableTrip(
831
+ BOARDING_STOP_INDEX,
832
+ ffbtRoute,
833
+ 0,
834
+ timeFromHMS(9, 30, 0),
835
+ undefined,
836
+ fromTripStop,
837
+ 30,
838
+ );
839
+ assert.strictEqual(result, 2);
840
+ });
841
+
842
+ it('treats departure exactly equal to after as satisfying (transferTime=0)', () => {
843
+ // after=10:00, transferTime=0 → requiredTime=10:00.
844
+ // trip 0 (08:00) < 10:00 → skip.
845
+ // trip 1 (09:00) NOT_AVAILABLE → skip.
846
+ // trip 2 (10:00) ≥ 10:00 → returned.
847
+ const result = ffbtTimetable.findFirstBoardableTrip(
848
+ BOARDING_STOP_INDEX,
849
+ ffbtRoute,
850
+ 0,
851
+ timeFromHMS(10, 0, 0),
852
+ undefined,
853
+ fromTripStop,
854
+ 0,
855
+ );
856
+ assert.strictEqual(result, 2);
857
+ });
858
+
859
+ it('returns undefined when all trips depart before after + transferTime', () => {
860
+ // after=11:00, transferTime=30 → requiredTime=11:30.
861
+ // trip 3 (11:00) < 11:30 → skip. No further trips.
862
+ const result = ffbtTimetable.findFirstBoardableTrip(
863
+ BOARDING_STOP_INDEX,
864
+ ffbtRoute,
865
+ 0,
866
+ timeFromHMS(11, 0, 0),
867
+ undefined,
868
+ fromTripStop,
869
+ 30,
870
+ );
871
+ assert.strictEqual(result, undefined);
872
+ });
873
+
874
+ it('returns undefined when beforeTrip excludes all trips that would satisfy the requirement', () => {
875
+ // Only trips 0 and 1 are in scope (beforeTrip=2).
876
+ // after=09:00, transferTime=30 → requiredTime=09:30.
877
+ // trip 0 (08:00) < 09:30 → skip.
878
+ // trip 1 (09:00) NOT_AVAILABLE → skip.
879
+ // beforeTrip reached → undefined.
880
+ const result = ffbtTimetable.findFirstBoardableTrip(
881
+ BOARDING_STOP_INDEX,
882
+ ffbtRoute,
883
+ 0,
884
+ timeFromHMS(9, 0, 0),
885
+ 2,
886
+ fromTripStop,
887
+ 30,
888
+ );
889
+ assert.strictEqual(result, undefined);
890
+ });
891
+ });
892
+
893
+ describe('guaranteed connection', () => {
894
+ it('bypasses the after + transferTime departure check', () => {
895
+ const fromTripStop: TripStop = {
896
+ routeId: 99,
897
+ stopIndex: 0,
898
+ tripIndex: 0,
899
+ };
900
+ const guaranteedTransfers: TripTransfers = new Map([
901
+ [
902
+ encode(
903
+ fromTripStop.stopIndex,
904
+ fromTripStop.routeId,
905
+ fromTripStop.tripIndex,
906
+ ),
907
+ [
908
+ {
909
+ stopIndex: BOARDING_STOP_INDEX,
910
+ routeId: FFBT_ROUTE_ID,
911
+ tripIndex: 0,
912
+ },
913
+ ],
914
+ ],
915
+ ]);
916
+ const timetableWithGuarantee = new Timetable(
917
+ ffbtStopsAdjacency,
918
+ [ffbtRoute],
919
+ ffbtServiceRoutes,
920
+ new Map(),
921
+ guaranteedTransfers,
922
+ );
923
+
924
+ // after=07:00, transferTime=120 → requiredTime=09:00; trip 0 (08:00) is below
925
+ // that threshold but the guarantee returns it immediately.
926
+ const result = timetableWithGuarantee.findFirstBoardableTrip(
927
+ BOARDING_STOP_INDEX,
928
+ ffbtRoute,
929
+ 0,
930
+ timeFromHMS(7, 0, 0),
931
+ undefined,
932
+ fromTripStop,
933
+ 120,
934
+ );
935
+ assert.strictEqual(result, 0);
936
+ });
937
+
938
+ it('does not bypass a NOT_AVAILABLE pickup', () => {
939
+ const fromTripStop: TripStop = {
940
+ routeId: 99,
941
+ stopIndex: 0,
942
+ tripIndex: 0,
943
+ };
944
+ const guaranteedTransfers: TripTransfers = new Map([
945
+ [
946
+ encode(
947
+ fromTripStop.stopIndex,
948
+ fromTripStop.routeId,
949
+ fromTripStop.tripIndex,
950
+ ),
951
+ [
952
+ {
953
+ stopIndex: BOARDING_STOP_INDEX,
954
+ routeId: FFBT_ROUTE_ID,
955
+ tripIndex: 1,
956
+ },
957
+ ],
958
+ ],
959
+ ]);
960
+ const timetableWithGuarantee = new Timetable(
961
+ ffbtStopsAdjacency,
962
+ [ffbtRoute],
963
+ ffbtServiceRoutes,
964
+ new Map(),
965
+ guaranteedTransfers,
966
+ );
967
+
968
+ // after=08:30, transferTime=60 → requiredTime=09:30.
969
+ // trip 0 (08:00): not guaranteed for trip 0, 08:00 < 09:30 → skip.
970
+ // trip 1 (09:00): NOT_AVAILABLE → skip (guarantee is irrelevant).
971
+ // trip 2 (10:00): no guarantee, but 10:00 ≥ 09:30 → first boardable.
972
+ const result = timetableWithGuarantee.findFirstBoardableTrip(
973
+ BOARDING_STOP_INDEX,
974
+ ffbtRoute,
975
+ 0,
976
+ timeFromHMS(8, 30, 0),
977
+ undefined,
978
+ fromTripStop,
979
+ 60,
980
+ );
981
+ assert.strictEqual(result, 2);
982
+ });
983
+
984
+ it('applies only to its declared target trip index', () => {
985
+ const fromTripStop: TripStop = {
986
+ routeId: 99,
987
+ stopIndex: 0,
988
+ tripIndex: 0,
989
+ };
990
+ const guaranteedTransfers: TripTransfers = new Map([
991
+ [
992
+ encode(
993
+ fromTripStop.stopIndex,
994
+ fromTripStop.routeId,
995
+ fromTripStop.tripIndex,
996
+ ),
997
+ [
998
+ {
999
+ stopIndex: BOARDING_STOP_INDEX,
1000
+ routeId: FFBT_ROUTE_ID,
1001
+ tripIndex: 2,
1002
+ },
1003
+ ],
1004
+ ],
1005
+ ]);
1006
+ const timetableWithGuarantee = new Timetable(
1007
+ ffbtStopsAdjacency,
1008
+ [ffbtRoute],
1009
+ ffbtServiceRoutes,
1010
+ new Map(),
1011
+ guaranteedTransfers,
1012
+ );
1013
+
1014
+ // after=09:30, transferTime=120 → requiredTime=11:30.
1015
+ // trip 0 (08:00): not guaranteed, 08:00 < 11:30 → skip.
1016
+ // trip 1 (09:00): NOT_AVAILABLE → skip.
1017
+ // trip 2 (10:00): guaranteed → returned immediately despite 10:00 < 11:30.
1018
+ const result = timetableWithGuarantee.findFirstBoardableTrip(
1019
+ BOARDING_STOP_INDEX,
1020
+ ffbtRoute,
1021
+ 0,
1022
+ timeFromHMS(9, 30, 0),
1023
+ undefined,
1024
+ fromTripStop,
1025
+ 120,
1026
+ );
1027
+ assert.strictEqual(result, 2);
1028
+ });
1029
+ });
1030
+ });
658
1031
  });
659
1032
  });