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
@@ -0,0 +1,1633 @@
1
+ import assert from 'node:assert';
2
+ import { beforeEach, describe, it } from 'node:test';
3
+
4
+ import { Stop } from '../../stops/stops.js';
5
+ import { StopsIndex } from '../../stops/stopsIndex.js';
6
+ import { Route } from '../../timetable/route.js';
7
+ import { durationFromSeconds, timeFromHM } from '../../timetable/time.js';
8
+ import {
9
+ ServiceRoute,
10
+ StopAdjacency,
11
+ Timetable,
12
+ TripTransfers,
13
+ } from '../../timetable/timetable.js';
14
+ import { encode } from '../../timetable/tripStopId.js';
15
+ import { AccessFinder } from '../access.js';
16
+ import { PlainRouter } from '../plainRouter.js';
17
+ import { Query } from '../query.js';
18
+ import { Raptor } from '../raptor.js';
19
+ import { Result } from '../result.js';
20
+
21
+ describe('PlainRouter', () => {
22
+ describe('with a single route', () => {
23
+ let router: PlainRouter;
24
+ let timetable: Timetable;
25
+
26
+ beforeEach(() => {
27
+ // Setup: A single route (Line 1) serving 3 stops in sequence
28
+ // Route 0: stop1 (depart 08:10) -> stop2 (08:15-08:25) -> stop3 (arrive 08:35)
29
+ const stopsAdjacency: StopAdjacency[] = [
30
+ { routes: [0] }, // stop 0 (stop1)
31
+ { routes: [0] }, // stop 1 (stop2)
32
+ { routes: [0] }, // stop 2 (stop3)
33
+ ];
34
+
35
+ const routesAdjacency = [
36
+ // Route 0: stops 0 -> 1 -> 2
37
+ Route.of({
38
+ id: 0,
39
+ serviceRouteId: 0,
40
+ trips: [
41
+ {
42
+ stops: [
43
+ {
44
+ id: 0,
45
+ arrivalTime: timeFromHM(8, 0),
46
+ departureTime: timeFromHM(8, 10),
47
+ },
48
+ {
49
+ id: 1,
50
+ arrivalTime: timeFromHM(8, 15),
51
+ departureTime: timeFromHM(8, 25),
52
+ },
53
+ {
54
+ id: 2,
55
+ arrivalTime: timeFromHM(8, 35),
56
+ departureTime: timeFromHM(8, 45),
57
+ },
58
+ ],
59
+ },
60
+ ],
61
+ }),
62
+ ];
63
+
64
+ const routes: ServiceRoute[] = [
65
+ {
66
+ type: 'BUS',
67
+ name: 'Line 1',
68
+ routes: [0],
69
+ },
70
+ ];
71
+
72
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
73
+
74
+ const stops: Stop[] = [
75
+ {
76
+ id: 0,
77
+ sourceStopId: 'stop1',
78
+ name: 'Stop 1',
79
+ lat: 1.0,
80
+ lon: 1.0,
81
+ children: [],
82
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
83
+ },
84
+ {
85
+ id: 1,
86
+ sourceStopId: 'stop2',
87
+ name: 'Stop 2',
88
+ lat: 2.0,
89
+ lon: 2.0,
90
+ children: [],
91
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
92
+ },
93
+ {
94
+ id: 2,
95
+ sourceStopId: 'stop3',
96
+ name: 'Stop 3',
97
+ lat: 3.0,
98
+ lon: 3.0,
99
+ children: [],
100
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
101
+ },
102
+ ];
103
+
104
+ const stopsIndex = new StopsIndex(stops);
105
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
106
+ const raptor = new Raptor(timetable);
107
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
108
+ });
109
+
110
+ it('should find a direct route', () => {
111
+ const query = new Query.Builder()
112
+ .from(0)
113
+ .to(2)
114
+ .departureTime(timeFromHM(8, 0))
115
+ .build();
116
+
117
+ const result: Result = router.route(query);
118
+ const bestRoute = result.bestRoute();
119
+
120
+ // Should find a single-leg direct route on Line 1: stop1 -> stop3
121
+ assert.strictEqual(bestRoute?.legs.length, 1);
122
+ });
123
+
124
+ it('should return an empty result when no route is possible', () => {
125
+ const query = new Query.Builder()
126
+ .from(0)
127
+ .to(12)
128
+ .departureTime(timeFromHM(8, 0))
129
+ .build();
130
+
131
+ const result: Result = router.route(query);
132
+ const bestRoute = result.bestRoute();
133
+
134
+ // No route exists to a non-existent stop
135
+ assert.strictEqual(bestRoute, undefined);
136
+ });
137
+
138
+ it('should correctly calculate the arrival time to a stop', () => {
139
+ const query = new Query.Builder()
140
+ .from(0)
141
+ .to(2)
142
+ .departureTime(timeFromHM(8, 0))
143
+ .build();
144
+
145
+ const result: Result = router.route(query);
146
+ const timeToStop3 = result.arrivalAt(2);
147
+
148
+ // Route 0 arrives at stop3 at 08:35
149
+ assert.strictEqual(timeToStop3?.arrival, timeFromHM(8, 35));
150
+ });
151
+ });
152
+
153
+ describe('with a route change', () => {
154
+ let router: PlainRouter;
155
+ let timetable: Timetable;
156
+
157
+ beforeEach(() => {
158
+ // Setup: Two routes that share stop2, enabling a same-stop transfer (route change)
159
+ // Route 0 (Line 1): stop1 (depart 08:15) -> stop2 (08:30-08:45) -> stop3 (09:00)
160
+ // Route 1 (Line 2): stop4 (depart 08:20) -> stop2 (09:00-09:15) -> stop5 (09:20)
161
+ // Both routes serve stop2, allowing transfer without walking
162
+ const stopsAdjacency: StopAdjacency[] = [
163
+ { routes: [0] }, // stop 0 (stop1)
164
+ { routes: [0, 1] }, // stop 1 (stop2) - shared by both routes
165
+ { routes: [0] }, // stop 2 (stop3)
166
+ { routes: [1] }, // stop 3 (stop4)
167
+ { routes: [1] }, // stop 4 (stop5)
168
+ ];
169
+
170
+ const routesAdjacency = [
171
+ // Route 0: stops 0 -> 1 -> 2
172
+ Route.of({
173
+ id: 0,
174
+ serviceRouteId: 0,
175
+ trips: [
176
+ {
177
+ stops: [
178
+ {
179
+ id: 0,
180
+ arrivalTime: timeFromHM(8, 0),
181
+ departureTime: timeFromHM(8, 15),
182
+ },
183
+ {
184
+ id: 1,
185
+ arrivalTime: timeFromHM(8, 30),
186
+ departureTime: timeFromHM(8, 45),
187
+ },
188
+ {
189
+ id: 2,
190
+ arrivalTime: timeFromHM(9, 0),
191
+ departureTime: timeFromHM(9, 10),
192
+ },
193
+ ],
194
+ },
195
+ ],
196
+ }),
197
+ // Route 1: stops 3 -> 1 -> 4
198
+ Route.of({
199
+ id: 1,
200
+ serviceRouteId: 1,
201
+ trips: [
202
+ {
203
+ stops: [
204
+ {
205
+ id: 3,
206
+ arrivalTime: timeFromHM(8, 5),
207
+ departureTime: timeFromHM(8, 20),
208
+ },
209
+ {
210
+ id: 1,
211
+ arrivalTime: timeFromHM(9, 0),
212
+ departureTime: timeFromHM(9, 15),
213
+ },
214
+ {
215
+ id: 4,
216
+ arrivalTime: timeFromHM(9, 20),
217
+ departureTime: timeFromHM(9, 35),
218
+ },
219
+ ],
220
+ },
221
+ ],
222
+ }),
223
+ ];
224
+
225
+ const routes: ServiceRoute[] = [
226
+ {
227
+ type: 'BUS',
228
+ name: 'Line 1',
229
+ routes: [0],
230
+ },
231
+ {
232
+ type: 'RAIL',
233
+ name: 'Line 2',
234
+ routes: [1],
235
+ },
236
+ ];
237
+
238
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
239
+
240
+ const stops: Stop[] = [
241
+ {
242
+ id: 0,
243
+ sourceStopId: 'stop1',
244
+ name: 'Stop 1',
245
+ lat: 1.0,
246
+ lon: 1.0,
247
+ children: [],
248
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
249
+ },
250
+ {
251
+ id: 1,
252
+ sourceStopId: 'stop2',
253
+ name: 'Stop 2',
254
+ lat: 2.0,
255
+ lon: 2.0,
256
+ children: [],
257
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
258
+ },
259
+ {
260
+ id: 2,
261
+ sourceStopId: 'stop3',
262
+ name: 'Stop 3',
263
+ lat: 3.0,
264
+ lon: 3.0,
265
+ children: [],
266
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
267
+ },
268
+ {
269
+ id: 3,
270
+ sourceStopId: 'stop4',
271
+ name: 'Stop 4',
272
+ lat: 4.0,
273
+ lon: 4.0,
274
+ children: [],
275
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
276
+ },
277
+ {
278
+ id: 4,
279
+ sourceStopId: 'stop5',
280
+ name: 'Stop 5',
281
+ lat: 5.0,
282
+ lon: 5.0,
283
+ children: [],
284
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
285
+ },
286
+ ];
287
+
288
+ const stopsIndex = new StopsIndex(stops);
289
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
290
+ const raptor = new Raptor(timetable);
291
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
292
+ });
293
+
294
+ it('should find a route with a change', () => {
295
+ const query = new Query.Builder()
296
+ .from(0)
297
+ .to(4)
298
+ .departureTime(timeFromHM(8, 0))
299
+ .build();
300
+
301
+ const result: Result = router.route(query);
302
+ const bestRoute = result.bestRoute();
303
+
304
+ // Should find a route with 2 legs:
305
+ // 1. Line 1: stop1 -> stop2 (arrive 08:30)
306
+ // 2. Line 2: stop2 -> stop5 (depart 09:15, arrive 09:20)
307
+ // Transfer at stop2 with 30 minutes to spare (default minTransferTime is 2 min)
308
+ assert.strictEqual(bestRoute?.legs.length, 2);
309
+ });
310
+
311
+ it('should correctly calculate the arrival time to a stop', () => {
312
+ const query = new Query.Builder()
313
+ .from(0)
314
+ .to(4)
315
+ .departureTime(timeFromHM(8, 0))
316
+ .build();
317
+
318
+ const result: Result = router.route(query);
319
+ const timeToStop5 = result.arrivalAt(4);
320
+
321
+ // Line 2 arrives at stop5 at 09:20
322
+ assert.strictEqual(timeToStop5?.arrival, timeFromHM(9, 20));
323
+ });
324
+ });
325
+
326
+ describe('with a walking transfer', () => {
327
+ let router: PlainRouter;
328
+ let timetable: Timetable;
329
+
330
+ beforeEach(() => {
331
+ // Setup: Two routes that don't share any stops, connected by a walking transfer
332
+ // Route 0 (Line 1): stop1 (depart 08:15) -> stop2 (08:25-08:35) -> stop3 (08:45)
333
+ // Route 1 (Line 2): stop4 (depart 08:20) -> stop5 (08:40-08:50) -> stop6 (09:10)
334
+ // Walking transfer from stop2 to stop5 with 5 minute minTransferTime
335
+ const stopsAdjacency: StopAdjacency[] = [
336
+ { routes: [0] }, // stop 0 (stop1)
337
+ {
338
+ transfers: [
339
+ {
340
+ destination: 4,
341
+ type: 'REQUIRES_MINIMAL_TIME',
342
+ minTransferTime: durationFromSeconds(300), // 5 minutes walking
343
+ },
344
+ ],
345
+ routes: [0],
346
+ }, // stop 1 (stop2) - has walking transfer to stop5
347
+ { routes: [0] }, // stop 2 (stop3)
348
+ { routes: [1] }, // stop 3 (stop4)
349
+ { routes: [1] }, // stop 4 (stop5)
350
+ { routes: [1] }, // stop 5 (stop6)
351
+ ];
352
+
353
+ const routesAdjacency = [
354
+ // Route 0: stops 0 -> 1 -> 2
355
+ Route.of({
356
+ id: 0,
357
+ serviceRouteId: 0,
358
+ trips: [
359
+ {
360
+ stops: [
361
+ {
362
+ id: 0,
363
+ arrivalTime: timeFromHM(8, 0),
364
+ departureTime: timeFromHM(8, 15),
365
+ },
366
+ {
367
+ id: 1,
368
+ arrivalTime: timeFromHM(8, 25),
369
+ departureTime: timeFromHM(8, 35),
370
+ },
371
+ {
372
+ id: 2,
373
+ arrivalTime: timeFromHM(8, 45),
374
+ departureTime: timeFromHM(8, 55),
375
+ },
376
+ ],
377
+ },
378
+ ],
379
+ }),
380
+ // Route 1: stops 3 -> 4 -> 5
381
+ Route.of({
382
+ id: 1,
383
+ serviceRouteId: 1,
384
+ trips: [
385
+ {
386
+ stops: [
387
+ {
388
+ id: 3,
389
+ arrivalTime: timeFromHM(8, 10),
390
+ departureTime: timeFromHM(8, 20),
391
+ },
392
+ {
393
+ id: 4,
394
+ arrivalTime: timeFromHM(8, 40),
395
+ departureTime: timeFromHM(8, 50),
396
+ },
397
+ {
398
+ id: 5,
399
+ arrivalTime: timeFromHM(9, 10),
400
+ departureTime: timeFromHM(9, 10),
401
+ },
402
+ ],
403
+ },
404
+ ],
405
+ }),
406
+ ];
407
+
408
+ const routes: ServiceRoute[] = [
409
+ {
410
+ type: 'BUS',
411
+ name: 'Line 1',
412
+ routes: [0],
413
+ },
414
+ {
415
+ type: 'RAIL',
416
+ name: 'Line 2',
417
+ routes: [1],
418
+ },
419
+ ];
420
+
421
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
422
+
423
+ const stops: Stop[] = [
424
+ {
425
+ id: 0,
426
+ sourceStopId: 'stop1',
427
+ name: 'Stop 1',
428
+ lat: 1.0,
429
+ lon: 1.0,
430
+ children: [],
431
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
432
+ },
433
+ {
434
+ id: 1,
435
+ sourceStopId: 'stop2',
436
+ name: 'Stop 2',
437
+ lat: 2.0,
438
+ lon: 2.0,
439
+ children: [],
440
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
441
+ },
442
+ {
443
+ id: 2,
444
+ sourceStopId: 'stop3',
445
+ name: 'Stop 3',
446
+ lat: 3.0,
447
+ lon: 3.0,
448
+ children: [],
449
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
450
+ },
451
+ {
452
+ id: 3,
453
+ sourceStopId: 'stop4',
454
+ name: 'Stop 4',
455
+ lat: 4.0,
456
+ lon: 4.0,
457
+ children: [],
458
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
459
+ },
460
+ {
461
+ id: 4,
462
+ sourceStopId: 'stop5',
463
+ name: 'Stop 5',
464
+ lat: 5.0,
465
+ lon: 5.0,
466
+ children: [],
467
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
468
+ },
469
+ {
470
+ id: 5,
471
+ sourceStopId: 'stop6',
472
+ name: 'Stop 6',
473
+ lat: 6.0,
474
+ lon: 6.0,
475
+ children: [],
476
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
477
+ },
478
+ ];
479
+
480
+ const stopsIndex = new StopsIndex(stops);
481
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
482
+ const raptor = new Raptor(timetable);
483
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
484
+ });
485
+
486
+ it('should find a route with a walking transfer', () => {
487
+ const query = new Query.Builder()
488
+ .from(0)
489
+ .to(5)
490
+ .departureTime(timeFromHM(8, 0))
491
+ .build();
492
+
493
+ const result: Result = router.route(query);
494
+ const bestRoute = result.bestRoute();
495
+
496
+ // Should find a route with 3 legs:
497
+ // 1. Line 1: stop1 -> stop2 (arrive 08:25)
498
+ // 2. Walking transfer: stop2 -> stop5 (5 min walk, arrive 08:30)
499
+ // 3. Line 2: stop5 -> stop6 (depart 08:50, arrive 09:10)
500
+ assert.strictEqual(bestRoute?.legs.length, 3);
501
+ });
502
+
503
+ it('should correctly calculate the arrival time at intermediate stop', () => {
504
+ const query = new Query.Builder()
505
+ .from(0)
506
+ .to(5)
507
+ .departureTime(timeFromHM(8, 0))
508
+ .build();
509
+
510
+ const result: Result = router.route(query);
511
+ const timeToStop5 = result.arrivalAt(4);
512
+
513
+ // Arrive at stop2 at 08:25, walk 5 min to stop5, arrive at 08:30
514
+ assert.strictEqual(timeToStop5?.arrival, timeFromHM(8, 30));
515
+ });
516
+ });
517
+
518
+ describe('with a faster change', () => {
519
+ let router: PlainRouter;
520
+ let timetable: Timetable;
521
+
522
+ beforeEach(() => {
523
+ // Setup: Three routes where Route 2 is direct but slower than Route 0 + Route 1 with a change
524
+ // Route 0 (Line 1): stop1 (08:15) -> stop2 (08:30-08:45) -> stop3 (09:00)
525
+ // Route 1 (Line 2): stop4 (08:25) -> stop2 (08:50-09:05) -> stop5 (09:10)
526
+ // Route 2 (Line 3): stop1 (08:15) -> stop5 (09:45) - direct but slower
527
+ // The router should prefer Route 0 + Route 1 (arrive 09:10) over Route 2 (arrive 09:45)
528
+ const stopsAdjacency: StopAdjacency[] = [
529
+ { routes: [0, 2] }, // stop 0 (stop1) - served by Line 1 and Line 3
530
+ { routes: [0, 1] }, // stop 1 (stop2) - transfer point
531
+ { routes: [0] }, // stop 2 (stop3)
532
+ { routes: [1] }, // stop 3 (stop4)
533
+ { routes: [1, 2] }, // stop 4 (stop5) - destination
534
+ ];
535
+
536
+ const routesAdjacency = [
537
+ // Route 0: stops 0 -> 1 -> 2
538
+ Route.of({
539
+ id: 0,
540
+ serviceRouteId: 0,
541
+ trips: [
542
+ {
543
+ stops: [
544
+ {
545
+ id: 0,
546
+ arrivalTime: timeFromHM(8, 0),
547
+ departureTime: timeFromHM(8, 15),
548
+ },
549
+ {
550
+ id: 1,
551
+ arrivalTime: timeFromHM(8, 30),
552
+ departureTime: timeFromHM(8, 45),
553
+ },
554
+ {
555
+ id: 2,
556
+ arrivalTime: timeFromHM(9, 0),
557
+ departureTime: timeFromHM(9, 15),
558
+ },
559
+ ],
560
+ },
561
+ ],
562
+ }),
563
+ // Route 1: stops 3 -> 1 -> 4
564
+ Route.of({
565
+ id: 1,
566
+ serviceRouteId: 1,
567
+ trips: [
568
+ {
569
+ stops: [
570
+ {
571
+ id: 3,
572
+ arrivalTime: timeFromHM(8, 10),
573
+ departureTime: timeFromHM(8, 25),
574
+ },
575
+ {
576
+ id: 1,
577
+ arrivalTime: timeFromHM(8, 50),
578
+ departureTime: timeFromHM(9, 5),
579
+ },
580
+ {
581
+ id: 4,
582
+ arrivalTime: timeFromHM(9, 10),
583
+ departureTime: timeFromHM(9, 25),
584
+ },
585
+ ],
586
+ },
587
+ ],
588
+ }),
589
+ // Route 2: stops 0 -> 4 (direct but slow)
590
+ Route.of({
591
+ id: 2,
592
+ serviceRouteId: 2,
593
+ trips: [
594
+ {
595
+ stops: [
596
+ {
597
+ id: 0,
598
+ arrivalTime: timeFromHM(8, 0),
599
+ departureTime: timeFromHM(8, 15),
600
+ },
601
+ {
602
+ id: 4,
603
+ arrivalTime: timeFromHM(9, 45),
604
+ departureTime: timeFromHM(10, 0),
605
+ },
606
+ ],
607
+ },
608
+ ],
609
+ }),
610
+ ];
611
+
612
+ const routes: ServiceRoute[] = [
613
+ {
614
+ type: 'BUS',
615
+ name: 'Line 1',
616
+ routes: [0],
617
+ },
618
+ {
619
+ type: 'RAIL',
620
+ name: 'Line 2',
621
+ routes: [1],
622
+ },
623
+ {
624
+ type: 'FERRY',
625
+ name: 'Line 3',
626
+ routes: [2],
627
+ },
628
+ ];
629
+
630
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
631
+
632
+ const stops: Stop[] = [
633
+ {
634
+ id: 0,
635
+ sourceStopId: 'stop1',
636
+ name: 'Stop 1',
637
+ lat: 1.0,
638
+ lon: 1.0,
639
+ children: [],
640
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
641
+ },
642
+ {
643
+ id: 1,
644
+ sourceStopId: 'stop2',
645
+ name: 'Stop 2',
646
+ lat: 2.0,
647
+ lon: 2.0,
648
+ children: [],
649
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
650
+ },
651
+ {
652
+ id: 2,
653
+ sourceStopId: 'stop3',
654
+ name: 'Stop 3',
655
+ lat: 3.0,
656
+ lon: 3.0,
657
+ children: [],
658
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
659
+ },
660
+ {
661
+ id: 3,
662
+ sourceStopId: 'stop4',
663
+ name: 'Stop 4',
664
+ lat: 4.0,
665
+ lon: 4.0,
666
+ children: [],
667
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
668
+ },
669
+ {
670
+ id: 4,
671
+ sourceStopId: 'stop5',
672
+ name: 'Stop 5',
673
+ lat: 5.0,
674
+ lon: 5.0,
675
+ children: [],
676
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
677
+ },
678
+ ];
679
+
680
+ const stopsIndex = new StopsIndex(stops);
681
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
682
+ const raptor = new Raptor(timetable);
683
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
684
+ });
685
+
686
+ it('should prefer a faster route with a change over a slower direct route', () => {
687
+ const query = new Query.Builder()
688
+ .from(0)
689
+ .to(4)
690
+ .departureTime(timeFromHM(8, 0))
691
+ .build();
692
+
693
+ const result: Result = router.route(query);
694
+ const bestRoute = result.bestRoute();
695
+
696
+ // Should prefer the 2-leg route (Line 1 + Line 2, arrive 09:10)
697
+ // over the 1-leg direct route (Line 3, arrive 09:45)
698
+ assert.strictEqual(bestRoute?.legs.length, 2);
699
+ });
700
+ });
701
+
702
+ describe('with route continuation (in-seat transfer)', () => {
703
+ let router: PlainRouter;
704
+ let timetable: Timetable;
705
+
706
+ beforeEach(() => {
707
+ // Setup: Route 0 continues as Route 1 at stop2 (same vehicle, different route number)
708
+ // This is an "in-seat transfer" where passengers can stay on the vehicle
709
+ // Route 0 (Line 1): stop1 (depart 08:10) -> stop2 (08:15-08:25)
710
+ // Route 1 (Line 2): stop2 (08:15-08:25) -> stop3 (08:35) -> stop4 (08:55)
711
+ // Trip continuation: Route 0 trip 0 at stop2 continues as Route 1 trip 0
712
+ // encode(1, 0, 0) = stop index 1 on route 0, trip 0 (where the continuation starts)
713
+ const tripContinuations: TripTransfers = new Map([
714
+ [encode(1, 0, 0), [{ stopIndex: 0, routeId: 1, tripIndex: 0 }]],
715
+ ]);
716
+
717
+ const stopsAdjacency: StopAdjacency[] = [
718
+ { routes: [0] }, // stop 0 (stop1)
719
+ { routes: [0, 1] }, // stop 1 (stop2) - continuation point
720
+ { routes: [1] }, // stop 2 (stop3)
721
+ { routes: [1] }, // stop 3 (stop4)
722
+ ];
723
+
724
+ const routesAdjacency = [
725
+ // Route 0: stops 0 -> 1
726
+ Route.of({
727
+ id: 0,
728
+ serviceRouteId: 0,
729
+ trips: [
730
+ {
731
+ stops: [
732
+ {
733
+ id: 0,
734
+ arrivalTime: timeFromHM(8, 0),
735
+ departureTime: timeFromHM(8, 10),
736
+ },
737
+ {
738
+ id: 1,
739
+ arrivalTime: timeFromHM(8, 15),
740
+ departureTime: timeFromHM(8, 25),
741
+ },
742
+ ],
743
+ },
744
+ ],
745
+ }),
746
+ // Route 1: stops 1 -> 2 -> 3 (continuation from route 0)
747
+ Route.of({
748
+ id: 1,
749
+ serviceRouteId: 1,
750
+ trips: [
751
+ {
752
+ stops: [
753
+ {
754
+ id: 1,
755
+ arrivalTime: timeFromHM(8, 15),
756
+ departureTime: timeFromHM(8, 25),
757
+ },
758
+ {
759
+ id: 2,
760
+ arrivalTime: timeFromHM(8, 35),
761
+ departureTime: timeFromHM(8, 45),
762
+ },
763
+ {
764
+ id: 3,
765
+ arrivalTime: timeFromHM(8, 55),
766
+ departureTime: timeFromHM(9, 5),
767
+ },
768
+ ],
769
+ },
770
+ ],
771
+ }),
772
+ ];
773
+
774
+ const routes: ServiceRoute[] = [
775
+ {
776
+ type: 'BUS',
777
+ name: 'Line 1',
778
+ routes: [0],
779
+ },
780
+ {
781
+ type: 'BUS',
782
+ name: 'Line 2',
783
+ routes: [1],
784
+ },
785
+ ];
786
+
787
+ timetable = new Timetable(
788
+ stopsAdjacency,
789
+ routesAdjacency,
790
+ routes,
791
+ tripContinuations,
792
+ );
793
+
794
+ const stops: Stop[] = [
795
+ {
796
+ id: 0,
797
+ sourceStopId: 'stop1',
798
+ name: 'Stop 1',
799
+ lat: 1.0,
800
+ lon: 1.0,
801
+ children: [],
802
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
803
+ },
804
+ {
805
+ id: 1,
806
+ sourceStopId: 'stop2',
807
+ name: 'Stop 2',
808
+ lat: 2.0,
809
+ lon: 2.0,
810
+ children: [],
811
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
812
+ },
813
+ {
814
+ id: 2,
815
+ sourceStopId: 'stop3',
816
+ name: 'Stop 3',
817
+ lat: 3.0,
818
+ lon: 3.0,
819
+ children: [],
820
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
821
+ },
822
+ {
823
+ id: 3,
824
+ sourceStopId: 'stop4',
825
+ name: 'Stop 4',
826
+ lat: 4.0,
827
+ lon: 4.0,
828
+ children: [],
829
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
830
+ },
831
+ ];
832
+
833
+ const stopsIndex = new StopsIndex(stops);
834
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
835
+ const raptor = new Raptor(timetable);
836
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
837
+ });
838
+
839
+ it('should find a route using continuation (in-seat transfer)', () => {
840
+ const query = new Query.Builder()
841
+ .from(0)
842
+ .to(3)
843
+ .departureTime(timeFromHM(8, 0))
844
+ .build();
845
+
846
+ const result: Result = router.route(query);
847
+ const bestRoute = result.bestRoute();
848
+
849
+ // Should find a route with only 1 leg because the continuation allows
850
+ // staying on the same vehicle when it changes route numbers
851
+ assert.strictEqual(bestRoute?.legs.length, 1);
852
+ });
853
+
854
+ it('should correctly calculate arrival time with continuation', () => {
855
+ const query = new Query.Builder()
856
+ .from(0)
857
+ .to(3)
858
+ .departureTime(timeFromHM(8, 0))
859
+ .build();
860
+
861
+ const result: Result = router.route(query);
862
+ const timeToStop4 = result.arrivalAt(3);
863
+
864
+ // Route 1 (continuation of Route 0) arrives at stop4 at 08:55
865
+ assert.strictEqual(timeToStop4?.arrival, timeFromHM(8, 55));
866
+ });
867
+ });
868
+
869
+ describe('with guaranteed trip transfers', () => {
870
+ let router: PlainRouter;
871
+ let timetable: Timetable;
872
+
873
+ beforeEach(() => {
874
+ // Setup: Route 0 trip 0 has a guaranteed transfer to Route 1 trip 0 at stop2
875
+ // The transfer time is only 1 minute (60 seconds), less than the 5-minute minTransferTime
876
+ // But since it's guaranteed, it should still be considered
877
+ // Route 0: stop1 (depart 08:10) -> stop2 (arrive 08:20)
878
+ // Route 1: stop2 (depart 08:21) -> stop3 (arrive 08:40)
879
+ // encode(1, 0, 0) = stop index 1 on route 0, trip 0 (where we alight)
880
+ // destination { stopIndex: 0, routeId: 1, tripIndex: 0 } = stop index 0 on route 1, trip 0 (where we board)
881
+ const guaranteedTripTransfers: TripTransfers = new Map([
882
+ [encode(1, 0, 0), [{ stopIndex: 0, routeId: 1, tripIndex: 0 }]],
883
+ ]);
884
+
885
+ const stopsAdjacency: StopAdjacency[] = [
886
+ { routes: [0] }, // stop 0 (stop1)
887
+ { routes: [0, 1] }, // stop 1 (stop2) - both routes serve this stop
888
+ { routes: [1] }, // stop 2 (stop3)
889
+ ];
890
+
891
+ const routesAdjacency = [
892
+ // Route 0: stops 0 -> 1
893
+ Route.of({
894
+ id: 0,
895
+ serviceRouteId: 0,
896
+ trips: [
897
+ {
898
+ stops: [
899
+ {
900
+ id: 0,
901
+ arrivalTime: timeFromHM(8, 0),
902
+ departureTime: timeFromHM(8, 10),
903
+ },
904
+ {
905
+ id: 1,
906
+ arrivalTime: timeFromHM(8, 20),
907
+ departureTime: timeFromHM(8, 30),
908
+ },
909
+ ],
910
+ },
911
+ ],
912
+ }),
913
+ // Route 1: stops 1 -> 2
914
+ // Departure at 08:21, only 1 minute after arrival from route 0
915
+ // Without the guaranteed transfer, this would be missed with a 5-minute minTransferTime
916
+ Route.of({
917
+ id: 1,
918
+ serviceRouteId: 1,
919
+ trips: [
920
+ {
921
+ stops: [
922
+ {
923
+ id: 1,
924
+ arrivalTime: timeFromHM(8, 15),
925
+ departureTime: timeFromHM(8, 21),
926
+ },
927
+ {
928
+ id: 2,
929
+ arrivalTime: timeFromHM(8, 40),
930
+ departureTime: timeFromHM(8, 50),
931
+ },
932
+ ],
933
+ },
934
+ ],
935
+ }),
936
+ ];
937
+
938
+ const routes: ServiceRoute[] = [
939
+ {
940
+ type: 'BUS',
941
+ name: 'Line 1',
942
+ routes: [0],
943
+ },
944
+ {
945
+ type: 'BUS',
946
+ name: 'Line 2',
947
+ routes: [1],
948
+ },
949
+ ];
950
+
951
+ timetable = new Timetable(
952
+ stopsAdjacency,
953
+ routesAdjacency,
954
+ routes,
955
+ undefined, // no trip continuations
956
+ guaranteedTripTransfers,
957
+ );
958
+
959
+ const stops: Stop[] = [
960
+ {
961
+ id: 0,
962
+ sourceStopId: 'stop1',
963
+ name: 'Stop 1',
964
+ lat: 1.0,
965
+ lon: 1.0,
966
+ children: [],
967
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
968
+ },
969
+ {
970
+ id: 1,
971
+ sourceStopId: 'stop2',
972
+ name: 'Stop 2',
973
+ lat: 2.0,
974
+ lon: 2.0,
975
+ children: [],
976
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
977
+ },
978
+ {
979
+ id: 2,
980
+ sourceStopId: 'stop3',
981
+ name: 'Stop 3',
982
+ lat: 3.0,
983
+ lon: 3.0,
984
+ children: [],
985
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
986
+ },
987
+ ];
988
+
989
+ const stopsIndex = new StopsIndex(stops);
990
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
991
+ const raptor = new Raptor(timetable);
992
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
993
+ });
994
+
995
+ it('should consider guaranteed transfer even with less time than minTransferTime', () => {
996
+ const query = new Query.Builder()
997
+ .from(0)
998
+ .to(2)
999
+ .departureTime(timeFromHM(8, 0))
1000
+ .minTransferTime(durationFromSeconds(300)) // 5 minutes, but transfer only has 1 minute
1001
+ .build();
1002
+
1003
+ const result: Result = router.route(query);
1004
+ const bestRoute = result.bestRoute();
1005
+
1006
+ // Should find a route with 3 legs:
1007
+ // 1. Vehicle leg (route 0)
1008
+ // 2. Guaranteed transfer leg (shown as type: 'GUARANTEED')
1009
+ // 3. Vehicle leg (route 1)
1010
+ assert.ok(bestRoute);
1011
+ assert.strictEqual(bestRoute.legs.length, 3);
1012
+
1013
+ // The middle leg should be the guaranteed transfer
1014
+ const transferLeg = bestRoute.legs[1];
1015
+ assert.ok(transferLeg && 'type' in transferLeg);
1016
+ assert.strictEqual(transferLeg.type, 'GUARANTEED');
1017
+
1018
+ // Should arrive at 08:40 because the guaranteed transfer allows catching
1019
+ // the 08:21 departure despite only having 1 minute of transfer time
1020
+ assert.strictEqual(result.arrivalAt(2)?.arrival, timeFromHM(8, 40));
1021
+ });
1022
+ });
1023
+
1024
+ describe('with non-guaranteed transfers and minTransferTime', () => {
1025
+ let router: PlainRouter;
1026
+ let timetable: Timetable;
1027
+
1028
+ beforeEach(() => {
1029
+ // Setup: Same-stop transfer (route change) without guaranteed transfer
1030
+ // Both routes serve stop2, so this is a route change at the same stop
1031
+ // The query's minTransferTime should be respected
1032
+ // Route 0: stop1 (depart 08:10) -> stop2 (arrive 08:20)
1033
+ // Route 1 trip 0: stop2 (depart 08:21) -> stop3 (arrive 08:35) - NOT catchable with 5 min minTransferTime
1034
+ // Route 1 trip 1: stop2 (depart 08:26) -> stop3 (arrive 08:45) - catchable with 5 min minTransferTime
1035
+ const stopsAdjacency: StopAdjacency[] = [
1036
+ { routes: [0] }, // stop 0 (stop1)
1037
+ { routes: [0, 1] }, // stop 1 (stop2) - both routes serve this stop
1038
+ { routes: [1] }, // stop 2 (stop3)
1039
+ ];
1040
+
1041
+ const routesAdjacency = [
1042
+ // Route 0: stops 0 -> 1
1043
+ Route.of({
1044
+ id: 0,
1045
+ serviceRouteId: 0,
1046
+ trips: [
1047
+ {
1048
+ stops: [
1049
+ {
1050
+ id: 0,
1051
+ arrivalTime: timeFromHM(8, 0),
1052
+ departureTime: timeFromHM(8, 10),
1053
+ },
1054
+ {
1055
+ id: 1,
1056
+ arrivalTime: timeFromHM(8, 20),
1057
+ departureTime: timeFromHM(8, 30),
1058
+ },
1059
+ ],
1060
+ },
1061
+ ],
1062
+ }),
1063
+ // Route 1: stops 1 -> 2
1064
+ // Trip 0: Departure at 08:21, only 1 minute after arrival - should NOT be catchable with 5 min minTransferTime
1065
+ // Trip 1: Departure at 08:26, 6 minutes after arrival - should be catchable with 5 min minTransferTime
1066
+ Route.of({
1067
+ id: 1,
1068
+ serviceRouteId: 1,
1069
+ trips: [
1070
+ {
1071
+ stops: [
1072
+ {
1073
+ id: 1,
1074
+ arrivalTime: timeFromHM(8, 15),
1075
+ departureTime: timeFromHM(8, 21),
1076
+ },
1077
+ {
1078
+ id: 2,
1079
+ arrivalTime: timeFromHM(8, 35),
1080
+ departureTime: timeFromHM(8, 45),
1081
+ },
1082
+ ],
1083
+ },
1084
+ {
1085
+ stops: [
1086
+ {
1087
+ id: 1,
1088
+ arrivalTime: timeFromHM(8, 20),
1089
+ departureTime: timeFromHM(8, 26),
1090
+ },
1091
+ {
1092
+ id: 2,
1093
+ arrivalTime: timeFromHM(8, 45),
1094
+ departureTime: timeFromHM(8, 55),
1095
+ },
1096
+ ],
1097
+ },
1098
+ ],
1099
+ }),
1100
+ ];
1101
+
1102
+ const routes: ServiceRoute[] = [
1103
+ {
1104
+ type: 'BUS',
1105
+ name: 'Line 1',
1106
+ routes: [0],
1107
+ },
1108
+ {
1109
+ type: 'BUS',
1110
+ name: 'Line 2',
1111
+ routes: [1],
1112
+ },
1113
+ ];
1114
+
1115
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
1116
+
1117
+ const stops: Stop[] = [
1118
+ {
1119
+ id: 0,
1120
+ sourceStopId: 'stop1',
1121
+ name: 'Stop 1',
1122
+ lat: 1.0,
1123
+ lon: 1.0,
1124
+ children: [],
1125
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1126
+ },
1127
+ {
1128
+ id: 1,
1129
+ sourceStopId: 'stop2',
1130
+ name: 'Stop 2',
1131
+ lat: 2.0,
1132
+ lon: 2.0,
1133
+ children: [],
1134
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1135
+ },
1136
+ {
1137
+ id: 2,
1138
+ sourceStopId: 'stop3',
1139
+ name: 'Stop 3',
1140
+ lat: 3.0,
1141
+ lon: 3.0,
1142
+ children: [],
1143
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1144
+ },
1145
+ ];
1146
+
1147
+ const stopsIndex = new StopsIndex(stops);
1148
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
1149
+ const raptor = new Raptor(timetable);
1150
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
1151
+ });
1152
+
1153
+ it('should not consider transfer with less time than minTransferTime', () => {
1154
+ const query = new Query.Builder()
1155
+ .from(0)
1156
+ .to(2)
1157
+ .departureTime(timeFromHM(8, 0))
1158
+ .minTransferTime(durationFromSeconds(300)) // 5 minutes required for transfer
1159
+ .build();
1160
+
1161
+ const result: Result = router.route(query);
1162
+ const bestRoute = result.bestRoute();
1163
+
1164
+ // Should find a route with 2 legs (same-stop transfer, no walking):
1165
+ // 1. Vehicle leg on route 0 (stop1 -> stop2)
1166
+ // 2. Vehicle leg on route 1 (stop2 -> stop3)
1167
+ assert.strictEqual(bestRoute?.legs.length, 2);
1168
+
1169
+ // Arrival at stop2 is 08:20, with 5 min minTransferTime we need departure >= 08:25
1170
+ // Trip 0 of route 1 departs at 08:21 - NOT catchable (only 1 minute transfer time)
1171
+ // Trip 1 of route 1 departs at 08:26 - catchable (6 minutes transfer time)
1172
+ // So we should arrive at stop3 at 08:45 (trip 1 arrival), not 08:35 (trip 0 arrival)
1173
+ assert.strictEqual(result.arrivalAt(2)?.arrival, timeFromHM(8, 45));
1174
+ });
1175
+ });
1176
+
1177
+ describe('with maxTransfers constraint', () => {
1178
+ let router: PlainRouter;
1179
+ let timetable: Timetable;
1180
+
1181
+ beforeEach(() => {
1182
+ // Setup: Three routes where reaching stop4 requires 2 transfers
1183
+ // Route 0: stop1 -> stop2
1184
+ // Route 1: stop2 -> stop3
1185
+ // Route 2: stop3 -> stop4
1186
+ // With maxTransfers=1, stop4 should not be reachable
1187
+ // With maxTransfers=2, stop4 should be reachable
1188
+ const stopsAdjacency: StopAdjacency[] = [
1189
+ { routes: [0] }, // stop 0 (stop1)
1190
+ { routes: [0, 1] }, // stop 1 (stop2)
1191
+ { routes: [1, 2] }, // stop 2 (stop3)
1192
+ { routes: [2] }, // stop 3 (stop4)
1193
+ ];
1194
+
1195
+ const routesAdjacency = [
1196
+ // Route 0: stops 0 -> 1
1197
+ Route.of({
1198
+ id: 0,
1199
+ serviceRouteId: 0,
1200
+ trips: [
1201
+ {
1202
+ stops: [
1203
+ {
1204
+ id: 0,
1205
+ arrivalTime: timeFromHM(8, 0),
1206
+ departureTime: timeFromHM(8, 10),
1207
+ },
1208
+ {
1209
+ id: 1,
1210
+ arrivalTime: timeFromHM(8, 20),
1211
+ departureTime: timeFromHM(8, 30),
1212
+ },
1213
+ ],
1214
+ },
1215
+ ],
1216
+ }),
1217
+ // Route 1: stops 1 -> 2
1218
+ Route.of({
1219
+ id: 1,
1220
+ serviceRouteId: 1,
1221
+ trips: [
1222
+ {
1223
+ stops: [
1224
+ {
1225
+ id: 1,
1226
+ arrivalTime: timeFromHM(8, 25),
1227
+ departureTime: timeFromHM(8, 35),
1228
+ },
1229
+ {
1230
+ id: 2,
1231
+ arrivalTime: timeFromHM(8, 45),
1232
+ departureTime: timeFromHM(8, 55),
1233
+ },
1234
+ ],
1235
+ },
1236
+ ],
1237
+ }),
1238
+ // Route 2: stops 2 -> 3
1239
+ Route.of({
1240
+ id: 2,
1241
+ serviceRouteId: 2,
1242
+ trips: [
1243
+ {
1244
+ stops: [
1245
+ {
1246
+ id: 2,
1247
+ arrivalTime: timeFromHM(8, 50),
1248
+ departureTime: timeFromHM(9, 0),
1249
+ },
1250
+ {
1251
+ id: 3,
1252
+ arrivalTime: timeFromHM(9, 10),
1253
+ departureTime: timeFromHM(9, 20),
1254
+ },
1255
+ ],
1256
+ },
1257
+ ],
1258
+ }),
1259
+ ];
1260
+
1261
+ const routes: ServiceRoute[] = [
1262
+ { type: 'BUS', name: 'Line 1', routes: [0] },
1263
+ { type: 'BUS', name: 'Line 2', routes: [1] },
1264
+ { type: 'BUS', name: 'Line 3', routes: [2] },
1265
+ ];
1266
+
1267
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
1268
+
1269
+ const stops: Stop[] = [
1270
+ {
1271
+ id: 0,
1272
+ sourceStopId: 'stop1',
1273
+ name: 'Stop 1',
1274
+ lat: 1.0,
1275
+ lon: 1.0,
1276
+ children: [],
1277
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1278
+ },
1279
+ {
1280
+ id: 1,
1281
+ sourceStopId: 'stop2',
1282
+ name: 'Stop 2',
1283
+ lat: 2.0,
1284
+ lon: 2.0,
1285
+ children: [],
1286
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1287
+ },
1288
+ {
1289
+ id: 2,
1290
+ sourceStopId: 'stop3',
1291
+ name: 'Stop 3',
1292
+ lat: 3.0,
1293
+ lon: 3.0,
1294
+ children: [],
1295
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1296
+ },
1297
+ {
1298
+ id: 3,
1299
+ sourceStopId: 'stop4',
1300
+ name: 'Stop 4',
1301
+ lat: 4.0,
1302
+ lon: 4.0,
1303
+ children: [],
1304
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1305
+ },
1306
+ ];
1307
+
1308
+ const stopsIndex = new StopsIndex(stops);
1309
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
1310
+ const raptor = new Raptor(timetable);
1311
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
1312
+ });
1313
+
1314
+ it('should not find route when maxTransfers is too low', () => {
1315
+ const query = new Query.Builder()
1316
+ .from(0)
1317
+ .to(3)
1318
+ .departureTime(timeFromHM(8, 0))
1319
+ .maxTransfers(1) // Only allows 1 transfer, but we need 2
1320
+ .build();
1321
+
1322
+ const result: Result = router.route(query);
1323
+ const bestRoute = result.bestRoute();
1324
+
1325
+ // Should not find a route because reaching stop4 requires 2 transfers
1326
+ assert.strictEqual(bestRoute, undefined);
1327
+ });
1328
+
1329
+ it('should find route when maxTransfers is sufficient', () => {
1330
+ const query = new Query.Builder()
1331
+ .from(0)
1332
+ .to(3)
1333
+ .departureTime(timeFromHM(8, 0))
1334
+ .maxTransfers(2) // Allows 2 transfers, which is exactly what we need
1335
+ .build();
1336
+
1337
+ const result: Result = router.route(query);
1338
+ const bestRoute = result.bestRoute();
1339
+
1340
+ // Should find a route with 3 legs (2 transfers)
1341
+ assert.strictEqual(bestRoute?.legs.length, 3);
1342
+ });
1343
+
1344
+ it('should find intermediate stops even with low maxTransfers', () => {
1345
+ const query = new Query.Builder()
1346
+ .from(0)
1347
+ .to(3)
1348
+ .departureTime(timeFromHM(8, 0))
1349
+ .maxTransfers(1)
1350
+ .build();
1351
+
1352
+ const result: Result = router.route(query);
1353
+
1354
+ // stop3 should still be reachable with 1 transfer (stop1 -> stop2 -> stop3)
1355
+ const arrivalAtStop3 = result.arrivalAt(2);
1356
+ assert.ok(arrivalAtStop3);
1357
+ assert.strictEqual(arrivalAtStop3.arrival, timeFromHM(8, 45));
1358
+ });
1359
+ });
1360
+
1361
+ describe('with transport mode filtering', () => {
1362
+ let router: PlainRouter;
1363
+ let timetable: Timetable;
1364
+
1365
+ beforeEach(() => {
1366
+ // Setup: Two routes to the same destination with different transport modes
1367
+ // Route 0 (BUS): stop1 -> stop2, arrives 08:30
1368
+ // Route 1 (RAIL): stop1 -> stop2, arrives 08:20 (faster)
1369
+ // When filtering to BUS only, should use the slower bus route
1370
+ const stopsAdjacency: StopAdjacency[] = [
1371
+ { routes: [0, 1] }, // stop 0 (stop1) - served by both routes
1372
+ { routes: [0, 1] }, // stop 1 (stop2) - served by both routes
1373
+ ];
1374
+
1375
+ const routesAdjacency = [
1376
+ // Route 0 (BUS): stops 0 -> 1, slower
1377
+ Route.of({
1378
+ id: 0,
1379
+ serviceRouteId: 0,
1380
+ trips: [
1381
+ {
1382
+ stops: [
1383
+ {
1384
+ id: 0,
1385
+ arrivalTime: timeFromHM(8, 0),
1386
+ departureTime: timeFromHM(8, 10),
1387
+ },
1388
+ {
1389
+ id: 1,
1390
+ arrivalTime: timeFromHM(8, 30),
1391
+ departureTime: timeFromHM(8, 40),
1392
+ },
1393
+ ],
1394
+ },
1395
+ ],
1396
+ }),
1397
+ // Route 1 (RAIL): stops 0 -> 1, faster
1398
+ Route.of({
1399
+ id: 1,
1400
+ serviceRouteId: 1,
1401
+ trips: [
1402
+ {
1403
+ stops: [
1404
+ {
1405
+ id: 0,
1406
+ arrivalTime: timeFromHM(8, 0),
1407
+ departureTime: timeFromHM(8, 10),
1408
+ },
1409
+ {
1410
+ id: 1,
1411
+ arrivalTime: timeFromHM(8, 20),
1412
+ departureTime: timeFromHM(8, 30),
1413
+ },
1414
+ ],
1415
+ },
1416
+ ],
1417
+ }),
1418
+ ];
1419
+
1420
+ const routes: ServiceRoute[] = [
1421
+ { type: 'BUS', name: 'Bus Line', routes: [0] },
1422
+ { type: 'RAIL', name: 'Rail Line', routes: [1] },
1423
+ ];
1424
+
1425
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
1426
+
1427
+ const stops: Stop[] = [
1428
+ {
1429
+ id: 0,
1430
+ sourceStopId: 'stop1',
1431
+ name: 'Stop 1',
1432
+ lat: 1.0,
1433
+ lon: 1.0,
1434
+ children: [],
1435
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1436
+ },
1437
+ {
1438
+ id: 1,
1439
+ sourceStopId: 'stop2',
1440
+ name: 'Stop 2',
1441
+ lat: 2.0,
1442
+ lon: 2.0,
1443
+ children: [],
1444
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1445
+ },
1446
+ ];
1447
+
1448
+ const stopsIndex = new StopsIndex(stops);
1449
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
1450
+ const raptor = new Raptor(timetable);
1451
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
1452
+ });
1453
+
1454
+ it('should use fastest route when all modes allowed', () => {
1455
+ const query = new Query.Builder()
1456
+ .from(0)
1457
+ .to(1)
1458
+ .departureTime(timeFromHM(8, 0))
1459
+ .build();
1460
+
1461
+ const result: Result = router.route(query);
1462
+
1463
+ // Should use the faster RAIL route, arriving at 08:20
1464
+ assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 20));
1465
+ });
1466
+
1467
+ it('should only use allowed transport modes', () => {
1468
+ const query = new Query.Builder()
1469
+ .from(0)
1470
+ .to(1)
1471
+ .departureTime(timeFromHM(8, 0))
1472
+ .transportModes(new Set(['BUS'] as const))
1473
+ .build();
1474
+
1475
+ const result: Result = router.route(query);
1476
+
1477
+ // Should use the slower BUS route since RAIL is excluded, arriving at 08:30
1478
+ assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
1479
+ });
1480
+
1481
+ it('should return no route when no matching transport mode', () => {
1482
+ const query = new Query.Builder()
1483
+ .from(0)
1484
+ .to(1)
1485
+ .departureTime(timeFromHM(8, 0))
1486
+ .transportModes(new Set(['FERRY'] as const)) // Neither route is a ferry
1487
+ .build();
1488
+
1489
+ const result: Result = router.route(query);
1490
+ const bestRoute = result.bestRoute();
1491
+
1492
+ // No route should be found since neither BUS nor RAIL is allowed
1493
+ assert.strictEqual(bestRoute, undefined);
1494
+ });
1495
+ });
1496
+
1497
+ describe('with timing edge cases', () => {
1498
+ let router: PlainRouter;
1499
+ let timetable: Timetable;
1500
+
1501
+ beforeEach(() => {
1502
+ // Setup: A single route with multiple trips at different times
1503
+ // Trip 0: departs 08:10, arrives 08:30
1504
+ // Trip 1: departs 09:10, arrives 09:30
1505
+ const stopsAdjacency: StopAdjacency[] = [
1506
+ { routes: [0] },
1507
+ { routes: [0] },
1508
+ ];
1509
+
1510
+ const routesAdjacency = [
1511
+ Route.of({
1512
+ id: 0,
1513
+ serviceRouteId: 0,
1514
+ trips: [
1515
+ {
1516
+ stops: [
1517
+ {
1518
+ id: 0,
1519
+ arrivalTime: timeFromHM(8, 0),
1520
+ departureTime: timeFromHM(8, 10),
1521
+ },
1522
+ {
1523
+ id: 1,
1524
+ arrivalTime: timeFromHM(8, 30),
1525
+ departureTime: timeFromHM(8, 40),
1526
+ },
1527
+ ],
1528
+ },
1529
+ {
1530
+ stops: [
1531
+ {
1532
+ id: 0,
1533
+ arrivalTime: timeFromHM(9, 0),
1534
+ departureTime: timeFromHM(9, 10),
1535
+ },
1536
+ {
1537
+ id: 1,
1538
+ arrivalTime: timeFromHM(9, 30),
1539
+ departureTime: timeFromHM(9, 40),
1540
+ },
1541
+ ],
1542
+ },
1543
+ ],
1544
+ }),
1545
+ ];
1546
+
1547
+ const routes: ServiceRoute[] = [
1548
+ { type: 'BUS', name: 'Line 1', routes: [0] },
1549
+ ];
1550
+
1551
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
1552
+
1553
+ const stops: Stop[] = [
1554
+ {
1555
+ id: 0,
1556
+ sourceStopId: 'stop1',
1557
+ name: 'Stop 1',
1558
+ lat: 1.0,
1559
+ lon: 1.0,
1560
+ children: [],
1561
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1562
+ },
1563
+ {
1564
+ id: 1,
1565
+ sourceStopId: 'stop2',
1566
+ name: 'Stop 2',
1567
+ lat: 2.0,
1568
+ lon: 2.0,
1569
+ children: [],
1570
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
1571
+ },
1572
+ ];
1573
+
1574
+ const stopsIndex = new StopsIndex(stops);
1575
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
1576
+ const raptor = new Raptor(timetable);
1577
+ router = new PlainRouter(timetable, stopsIndex, accessFinder, raptor);
1578
+ });
1579
+
1580
+ it('should find first available trip after departure time', () => {
1581
+ const query = new Query.Builder()
1582
+ .from(0)
1583
+ .to(1)
1584
+ .departureTime(timeFromHM(8, 0))
1585
+ .build();
1586
+
1587
+ const result: Result = router.route(query);
1588
+
1589
+ // Should catch the first trip (08:10), arriving at 08:30
1590
+ assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
1591
+ });
1592
+
1593
+ it('should skip trips that have already departed', () => {
1594
+ const query = new Query.Builder()
1595
+ .from(0)
1596
+ .to(1)
1597
+ .departureTime(timeFromHM(8, 15)) // After first trip departs
1598
+ .build();
1599
+
1600
+ const result: Result = router.route(query);
1601
+
1602
+ // Should catch the second trip (09:10), arriving at 09:30
1603
+ assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(9, 30));
1604
+ });
1605
+
1606
+ it('should return no route when departing after all trips', () => {
1607
+ const query = new Query.Builder()
1608
+ .from(0)
1609
+ .to(1)
1610
+ .departureTime(timeFromHM(10, 0)) // After all trips have departed
1611
+ .build();
1612
+
1613
+ const result: Result = router.route(query);
1614
+ const bestRoute = result.bestRoute();
1615
+
1616
+ // No route should be found since all trips have departed
1617
+ assert.strictEqual(bestRoute, undefined);
1618
+ });
1619
+
1620
+ it('should catch trip when departure time exactly matches', () => {
1621
+ const query = new Query.Builder()
1622
+ .from(0)
1623
+ .to(1)
1624
+ .departureTime(timeFromHM(8, 10)) // Exactly when first trip departs
1625
+ .build();
1626
+
1627
+ const result: Result = router.route(query);
1628
+
1629
+ // Should still catch the first trip
1630
+ assert.strictEqual(result.arrivalAt(1)?.arrival, timeFromHM(8, 30));
1631
+ });
1632
+ });
1633
+ });