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,472 @@
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
+ } from '../../timetable/timetable.js';
13
+ import { AccessFinder } from '../access.js';
14
+ import { RangeQuery } from '../query.js';
15
+ import { RangeRouter } from '../rangeRouter.js';
16
+ import { Raptor } from '../raptor.js';
17
+
18
+ describe('RangeRouter', () => {
19
+ describe('with initial walking access', () => {
20
+ let router: RangeRouter;
21
+
22
+ beforeEach(() => {
23
+ const stopsAdjacency: StopAdjacency[] = [
24
+ {
25
+ routes: [],
26
+ transfers: [{ destination: 1, type: 'REQUIRES_MINIMAL_TIME' }],
27
+ },
28
+ { routes: [0] },
29
+ { routes: [0] },
30
+ ];
31
+
32
+ const routesAdjacency = [
33
+ Route.of({
34
+ id: 0,
35
+ serviceRouteId: 0,
36
+ trips: [
37
+ {
38
+ stops: [
39
+ {
40
+ id: 1,
41
+ arrivalTime: timeFromHM(8, 5),
42
+ departureTime: timeFromHM(8, 5),
43
+ },
44
+ {
45
+ id: 2,
46
+ arrivalTime: timeFromHM(8, 20),
47
+ departureTime: timeFromHM(8, 20),
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ }),
53
+ ];
54
+
55
+ const serviceRoutes: ServiceRoute[] = [
56
+ { type: 'BUS', name: 'Line 1', routes: [0] },
57
+ ];
58
+
59
+ const timetable = new Timetable(
60
+ stopsAdjacency,
61
+ routesAdjacency,
62
+ serviceRoutes,
63
+ );
64
+
65
+ const stops: Stop[] = [
66
+ {
67
+ id: 0,
68
+ sourceStopId: 'origin',
69
+ name: 'Origin',
70
+ lat: 0,
71
+ lon: 0,
72
+ children: [],
73
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
74
+ },
75
+ {
76
+ id: 1,
77
+ sourceStopId: 'boarding',
78
+ name: 'Boarding',
79
+ lat: 1,
80
+ lon: 1,
81
+ children: [],
82
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
83
+ },
84
+ {
85
+ id: 2,
86
+ sourceStopId: 'destination',
87
+ name: 'Destination',
88
+ lat: 2,
89
+ lon: 2,
90
+ children: [],
91
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
92
+ },
93
+ ];
94
+
95
+ const stopsIndex = new StopsIndex(stops);
96
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
97
+ const raptor = new Raptor(timetable);
98
+ router = new RangeRouter(timetable, stopsIndex, accessFinder, raptor);
99
+ });
100
+
101
+ it('should reconstruct the initial access leg with the fallback transfer time', () => {
102
+ const query = new RangeQuery.Builder()
103
+ .from(0)
104
+ .to(2)
105
+ .departureTime(timeFromHM(8, 0))
106
+ .lastDepartureTime(timeFromHM(8, 0))
107
+ .minTransferTime(durationFromSeconds(300))
108
+ .build();
109
+
110
+ const result = router.rangeRoute(query);
111
+ const route = result.bestRoute();
112
+
113
+ assert(route);
114
+ assert.strictEqual(route.legs.length, 2);
115
+ assert.strictEqual(route.departureTime(), timeFromHM(8, 0));
116
+ assert.strictEqual(route.arrivalTime(), timeFromHM(8, 20));
117
+ const firstLeg = route.legs[0];
118
+ assert(firstLeg);
119
+ assert.strictEqual(firstLeg.from.id, 0);
120
+ assert.strictEqual(firstLeg.to.id, 1);
121
+ });
122
+ });
123
+
124
+ describe('with multiple Pareto-optimal runs', () => {
125
+ // Base timetable: two-stop network, one route with two trips.
126
+ // trip 0: stop 0 departs 08:00, stop 1 arrives 08:30
127
+ // trip 1: stop 0 departs 08:30, stop 1 arrives 09:00
128
+ // Neither trip dominates the other (trip 1 departs later but arrives later).
129
+ let timetable: Timetable;
130
+ let router: RangeRouter;
131
+
132
+ beforeEach(() => {
133
+ const stopsAdjacency: StopAdjacency[] = [
134
+ { routes: [0] },
135
+ { routes: [0] },
136
+ ];
137
+
138
+ const routesAdjacency = [
139
+ Route.of({
140
+ id: 0,
141
+ serviceRouteId: 0,
142
+ trips: [
143
+ {
144
+ stops: [
145
+ {
146
+ id: 0,
147
+ arrivalTime: timeFromHM(8, 0),
148
+ departureTime: timeFromHM(8, 0),
149
+ },
150
+ {
151
+ id: 1,
152
+ arrivalTime: timeFromHM(8, 30),
153
+ departureTime: timeFromHM(8, 30),
154
+ },
155
+ ],
156
+ },
157
+ {
158
+ stops: [
159
+ {
160
+ id: 0,
161
+ arrivalTime: timeFromHM(8, 30),
162
+ departureTime: timeFromHM(8, 30),
163
+ },
164
+ {
165
+ id: 1,
166
+ arrivalTime: timeFromHM(9, 0),
167
+ departureTime: timeFromHM(9, 0),
168
+ },
169
+ ],
170
+ },
171
+ ],
172
+ }),
173
+ ];
174
+
175
+ const serviceRoutes: ServiceRoute[] = [
176
+ { type: 'BUS', name: 'Line 1', routes: [0] },
177
+ ];
178
+
179
+ timetable = new Timetable(stopsAdjacency, routesAdjacency, serviceRoutes);
180
+
181
+ const stops: Stop[] = [
182
+ {
183
+ id: 0,
184
+ sourceStopId: 'origin',
185
+ name: 'Origin',
186
+ lat: 0,
187
+ lon: 0,
188
+ children: [],
189
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
190
+ },
191
+ {
192
+ id: 1,
193
+ sourceStopId: 'dest',
194
+ name: 'Destination',
195
+ lat: 0,
196
+ lon: 0,
197
+ children: [],
198
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
199
+ },
200
+ ];
201
+
202
+ const stopsIndex = new StopsIndex(stops);
203
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
204
+ const raptor = new Raptor(timetable);
205
+ router = new RangeRouter(timetable, stopsIndex, accessFinder, raptor);
206
+ });
207
+
208
+ it('returns one run per non-dominated departure', () => {
209
+ const query = new RangeQuery.Builder()
210
+ .from(0)
211
+ .to(1)
212
+ .departureTime(timeFromHM(8, 0))
213
+ .lastDepartureTime(timeFromHM(8, 30))
214
+ .build();
215
+
216
+ const result = router.rangeRoute(query);
217
+
218
+ // Both trips are Pareto-optimal.
219
+ assert.strictEqual(result.size, 2);
220
+
221
+ // The latest departure route leaves at 08:30.
222
+ const latest = result.latestDepartureRoute();
223
+ assert(latest);
224
+ assert.strictEqual(latest.departureTime(), timeFromHM(8, 30));
225
+ assert.strictEqual(latest.arrivalTime(), timeFromHM(9, 0));
226
+
227
+ // The best route (earliest arrival) leaves at 08:00.
228
+ const best = result.bestRoute();
229
+ assert(best);
230
+ assert.strictEqual(best.arrivalTime(), timeFromHM(8, 30));
231
+ assert.strictEqual(best.departureTime(), timeFromHM(8, 0));
232
+ });
233
+
234
+ it('excludes a departure dominated by a later trip', () => {
235
+ // Rebuild with a timetable where:
236
+ // trip 0: departs 08:00 → arrives 09:00 (slower)
237
+ // trip 1: departs 08:30 → arrives 08:50 (faster; dominates trip 0)
238
+ const dominatingAdj: StopAdjacency[] = [{ routes: [0] }, { routes: [0] }];
239
+
240
+ const dominatingRoutes = [
241
+ Route.of({
242
+ id: 0,
243
+ serviceRouteId: 0,
244
+ trips: [
245
+ {
246
+ stops: [
247
+ {
248
+ id: 0,
249
+ arrivalTime: timeFromHM(8, 0),
250
+ departureTime: timeFromHM(8, 0),
251
+ },
252
+ {
253
+ id: 1,
254
+ arrivalTime: timeFromHM(9, 0),
255
+ departureTime: timeFromHM(9, 0),
256
+ },
257
+ ],
258
+ },
259
+ {
260
+ stops: [
261
+ {
262
+ id: 0,
263
+ arrivalTime: timeFromHM(8, 30),
264
+ departureTime: timeFromHM(8, 30),
265
+ },
266
+ {
267
+ id: 1,
268
+ arrivalTime: timeFromHM(8, 50),
269
+ departureTime: timeFromHM(8, 50),
270
+ },
271
+ ],
272
+ },
273
+ ],
274
+ }),
275
+ ];
276
+
277
+ const serviceRoutes: ServiceRoute[] = [
278
+ { type: 'BUS', name: 'Line 1', routes: [0] },
279
+ ];
280
+
281
+ const dominatingTimetable = new Timetable(
282
+ dominatingAdj,
283
+ dominatingRoutes,
284
+ serviceRoutes,
285
+ );
286
+
287
+ const stops: Stop[] = [
288
+ {
289
+ id: 0,
290
+ sourceStopId: 'origin',
291
+ name: 'Origin',
292
+ lat: 0,
293
+ lon: 0,
294
+ children: [],
295
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
296
+ },
297
+ {
298
+ id: 1,
299
+ sourceStopId: 'dest',
300
+ name: 'Destination',
301
+ lat: 0,
302
+ lon: 0,
303
+ children: [],
304
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
305
+ },
306
+ ];
307
+
308
+ const stopsIndex = new StopsIndex(stops);
309
+ const accessFinder = new AccessFinder(dominatingTimetable, stopsIndex);
310
+ const raptor = new Raptor(dominatingTimetable);
311
+ const dominatingRouter = new RangeRouter(
312
+ dominatingTimetable,
313
+ stopsIndex,
314
+ accessFinder,
315
+ raptor,
316
+ );
317
+
318
+ const query = new RangeQuery.Builder()
319
+ .from(0)
320
+ .to(1)
321
+ .departureTime(timeFromHM(8, 0))
322
+ .lastDepartureTime(timeFromHM(8, 30))
323
+ .build();
324
+
325
+ const result = dominatingRouter.rangeRoute(query);
326
+
327
+ // Only trip 1 (08:30 → 08:50) is Pareto-optimal; trip 0 (08:00 → 09:00)
328
+ // is dominated because it departs earlier yet arrives later.
329
+ assert.strictEqual(result.size, 1);
330
+ const route = result.bestRoute();
331
+ assert(route);
332
+ assert.strictEqual(route.departureTime(), timeFromHM(8, 30));
333
+ assert.strictEqual(route.arrivalTime(), timeFromHM(8, 50));
334
+ });
335
+
336
+ it('returns empty result when no trip falls in the window', () => {
337
+ const query = new RangeQuery.Builder()
338
+ .from(0)
339
+ .to(1)
340
+ .departureTime(timeFromHM(10, 0))
341
+ .lastDepartureTime(timeFromHM(11, 0))
342
+ .build();
343
+
344
+ const result = router.rangeRoute(query);
345
+
346
+ assert.strictEqual(result.size, 0);
347
+ assert.strictEqual(result.bestRoute(), undefined);
348
+ });
349
+ });
350
+
351
+ describe('same-stop query (origin equals destination)', () => {
352
+ // Network: two stops, one route with two trips both departing from stop 0.
353
+ // The query goes from stop 0 to stop 0, so the destination is trivially
354
+ // reachable in round 0 with zero duration for every departure slot.
355
+ // Without the trivial-destination guard, every slot would be stored as a
356
+ // Pareto-optimal run (O(trips) runs). With the fix, only one run is kept.
357
+ let router: RangeRouter;
358
+
359
+ beforeEach(() => {
360
+ const stopsAdjacency: StopAdjacency[] = [
361
+ { routes: [0] },
362
+ { routes: [0] },
363
+ ];
364
+
365
+ const routesAdjacency = [
366
+ Route.of({
367
+ id: 0,
368
+ serviceRouteId: 0,
369
+ trips: [
370
+ {
371
+ stops: [
372
+ {
373
+ id: 0,
374
+ arrivalTime: timeFromHM(8, 0),
375
+ departureTime: timeFromHM(8, 0),
376
+ },
377
+ {
378
+ id: 1,
379
+ arrivalTime: timeFromHM(8, 30),
380
+ departureTime: timeFromHM(8, 30),
381
+ },
382
+ ],
383
+ },
384
+ {
385
+ stops: [
386
+ {
387
+ id: 0,
388
+ arrivalTime: timeFromHM(8, 30),
389
+ departureTime: timeFromHM(8, 30),
390
+ },
391
+ {
392
+ id: 1,
393
+ arrivalTime: timeFromHM(9, 0),
394
+ departureTime: timeFromHM(9, 0),
395
+ },
396
+ ],
397
+ },
398
+ ],
399
+ }),
400
+ ];
401
+
402
+ const serviceRoutes: ServiceRoute[] = [
403
+ { type: 'BUS', name: 'Line 1', routes: [0] },
404
+ ];
405
+
406
+ const timetable = new Timetable(
407
+ stopsAdjacency,
408
+ routesAdjacency,
409
+ serviceRoutes,
410
+ );
411
+
412
+ const stops: Stop[] = [
413
+ {
414
+ id: 0,
415
+ sourceStopId: 'stop',
416
+ name: 'Stop',
417
+ lat: 0,
418
+ lon: 0,
419
+ children: [],
420
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
421
+ },
422
+ {
423
+ id: 1,
424
+ sourceStopId: 'other',
425
+ name: 'Other',
426
+ lat: 0,
427
+ lon: 0,
428
+ children: [],
429
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
430
+ },
431
+ ];
432
+
433
+ const stopsIndex = new StopsIndex(stops);
434
+ const accessFinder = new AccessFinder(timetable, stopsIndex);
435
+ const raptor = new Raptor(timetable);
436
+ router = new RangeRouter(timetable, stopsIndex, accessFinder, raptor);
437
+ });
438
+
439
+ it('produces exactly one Pareto run even when multiple departure slots fall in the window', () => {
440
+ // The window covers both trips departing from stop 0 (08:00 and 08:30),
441
+ // so collectDepartureTimes generates two slots. Without the guard each
442
+ // would be stored as Pareto-optimal (arrival = depTime for same-stop).
443
+ const query = new RangeQuery.Builder()
444
+ .from(0)
445
+ .to(0)
446
+ .departureTime(timeFromHM(8, 0))
447
+ .lastDepartureTime(timeFromHM(8, 30))
448
+ .build();
449
+
450
+ const result = router.rangeRoute(query);
451
+
452
+ assert.strictEqual(result.size, 1);
453
+ });
454
+
455
+ it('stores the latest-departing trivial run', () => {
456
+ const query = new RangeQuery.Builder()
457
+ .from(0)
458
+ .to(0)
459
+ .departureTime(timeFromHM(8, 0))
460
+ .lastDepartureTime(timeFromHM(8, 30))
461
+ .build();
462
+
463
+ const result = router.rangeRoute(query);
464
+
465
+ // ParetoRun.departureTime is the origin departure; the reconstructed
466
+ // Route has no vehicle legs so route.departureTime() is not available.
467
+ const [run] = result;
468
+ assert(run);
469
+ assert.strictEqual(run.departureTime, timeFromHM(8, 30));
470
+ });
471
+ });
472
+ });
@@ -0,0 +1,246 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+ import assert from 'node:assert';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import { timeFromHM } from '../../timetable/time.js';
6
+ import { RangeRaptorState } from '../rangeState.js';
7
+ import { RoutingState, UNREACHED_TIME } from '../state.js';
8
+
9
+ const NB_STOPS = 4;
10
+ const MAX_ROUNDS = 3;
11
+
12
+ describe('RangeRaptorState', () => {
13
+ describe('constructor', () => {
14
+ it('creates roundLabels of length maxRounds + 2', () => {
15
+ const state = new RangeRaptorState(
16
+ MAX_ROUNDS,
17
+ NB_STOPS,
18
+ timeFromHM(12, 0),
19
+ );
20
+ assert.strictEqual(state.roundLabels.length, MAX_ROUNDS + 2);
21
+ });
22
+
23
+ it('initialized all roundLabels to UNREACHED_TIME', () => {
24
+ const state = new RangeRaptorState(
25
+ MAX_ROUNDS,
26
+ NB_STOPS,
27
+ timeFromHM(12, 0),
28
+ );
29
+ for (const round of state.roundLabels) {
30
+ for (const val of round) {
31
+ assert.strictEqual(val, UNREACHED_TIME);
32
+ }
33
+ }
34
+ });
35
+
36
+ it('stores latestDeparture', () => {
37
+ const latest = timeFromHM(11, 30);
38
+ const state = new RangeRaptorState(MAX_ROUNDS, NB_STOPS, latest);
39
+ assert.strictEqual(state.latestDeparture, latest);
40
+ });
41
+ });
42
+
43
+ describe('setCurrentRun', () => {
44
+ it('seeds round-0 shared labels from origin nodes', () => {
45
+ const state = new RangeRaptorState(
46
+ MAX_ROUNDS,
47
+ NB_STOPS,
48
+ timeFromHM(12, 0),
49
+ );
50
+ state.setCurrentRun(
51
+ RoutingState.fromTestData({
52
+ nbStops: NB_STOPS,
53
+ origins: [1],
54
+ graph: [[[1, { stopId: 1, arrival: timeFromHM(9, 0) }]]],
55
+ }),
56
+ );
57
+ assert.strictEqual(state.roundLabels[0]![1], timeFromHM(9, 0));
58
+ });
59
+
60
+ it('skips an origin that has no edge in round 0', () => {
61
+ const state = new RangeRaptorState(
62
+ MAX_ROUNDS,
63
+ NB_STOPS,
64
+ timeFromHM(12, 0),
65
+ );
66
+ state.setCurrentRun(
67
+ RoutingState.fromTestData({
68
+ nbStops: NB_STOPS,
69
+ origins: [1],
70
+ graph: [[]],
71
+ }),
72
+ );
73
+ assert.strictEqual(state.roundLabels[0]![1], UNREACHED_TIME);
74
+ });
75
+
76
+ it('delegates origins and graph getters to the active run', () => {
77
+ const state = new RangeRaptorState(
78
+ MAX_ROUNDS,
79
+ NB_STOPS,
80
+ timeFromHM(12, 0),
81
+ );
82
+ const run = RoutingState.fromTestData({
83
+ nbStops: NB_STOPS,
84
+ origins: [0, 2],
85
+ graph: [[]],
86
+ });
87
+ state.setCurrentRun(run);
88
+ assert.deepStrictEqual(state.origins, [0, 2]);
89
+ assert.strictEqual(state.graph, run.graph);
90
+ });
91
+ });
92
+
93
+ describe('improvementBound', () => {
94
+ it('returns the cross-run shared label, tighter than the per-run arrival', () => {
95
+ const state = new RangeRaptorState(
96
+ MAX_ROUNDS,
97
+ NB_STOPS,
98
+ timeFromHM(12, 0),
99
+ );
100
+
101
+ state.setCurrentRun(
102
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
103
+ );
104
+ state.updateArrival(2, timeFromHM(9, 30), 1);
105
+
106
+ state.setCurrentRun(
107
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
108
+ );
109
+
110
+ assert.strictEqual(state.arrivalTime(2), UNREACHED_TIME);
111
+ assert.strictEqual(state.improvementBound(1, 2), timeFromHM(9, 30));
112
+ });
113
+ });
114
+
115
+ describe('updateArrival', () => {
116
+ it('improves the shared roundLabel for the given round and stop', () => {
117
+ const state = new RangeRaptorState(
118
+ MAX_ROUNDS,
119
+ NB_STOPS,
120
+ timeFromHM(12, 0),
121
+ );
122
+ state.setCurrentRun(
123
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
124
+ );
125
+ state.updateArrival(2, timeFromHM(9, 0), 1);
126
+ assert.strictEqual(state.roundLabels[1]![2], timeFromHM(9, 0));
127
+ });
128
+
129
+ it('does not worsen a shared roundLabel that is already tight', () => {
130
+ const state = new RangeRaptorState(
131
+ MAX_ROUNDS,
132
+ NB_STOPS,
133
+ timeFromHM(12, 0),
134
+ );
135
+ state.setCurrentRun(
136
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
137
+ );
138
+ state.updateArrival(2, timeFromHM(9, 0), 1);
139
+ state.updateArrival(2, timeFromHM(9, 30), 1);
140
+ assert.strictEqual(state.roundLabels[1]![2], timeFromHM(9, 0));
141
+ });
142
+
143
+ it('updates destinationBest when a destination stop is first reached', () => {
144
+ const state = new RangeRaptorState(
145
+ MAX_ROUNDS,
146
+ NB_STOPS,
147
+ timeFromHM(12, 0),
148
+ );
149
+ state.setCurrentRun(
150
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [3] }),
151
+ );
152
+ assert.strictEqual(state.destinationBest, UNREACHED_TIME);
153
+ state.updateArrival(3, timeFromHM(10, 0), 1);
154
+ assert.strictEqual(state.destinationBest, timeFromHM(10, 0));
155
+ });
156
+
157
+ it('destinationBest persists when the run is swapped', () => {
158
+ const state = new RangeRaptorState(
159
+ MAX_ROUNDS,
160
+ NB_STOPS,
161
+ timeFromHM(12, 0),
162
+ );
163
+ state.setCurrentRun(
164
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [3] }),
165
+ );
166
+ state.updateArrival(3, timeFromHM(10, 0), 1);
167
+
168
+ state.setCurrentRun(
169
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [3] }),
170
+ );
171
+ assert.strictEqual(state.destinationBest, timeFromHM(10, 0));
172
+ });
173
+ });
174
+
175
+ describe('initRound', () => {
176
+ it('propagates the round k-1 label into round k for changed stops', () => {
177
+ const state = new RangeRaptorState(
178
+ MAX_ROUNDS,
179
+ NB_STOPS,
180
+ timeFromHM(12, 0),
181
+ );
182
+ state.setCurrentRun(
183
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
184
+ );
185
+ state.updateArrival(2, timeFromHM(9, 0), 1);
186
+ state.initRound(2);
187
+ assert.strictEqual(state.roundLabels[2]![2], timeFromHM(9, 0));
188
+ });
189
+
190
+ it('does not overwrite a tighter label already present in round k', () => {
191
+ const state = new RangeRaptorState(
192
+ MAX_ROUNDS,
193
+ NB_STOPS,
194
+ timeFromHM(12, 0),
195
+ );
196
+ state.setCurrentRun(
197
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [2] }),
198
+ );
199
+ state.updateArrival(2, timeFromHM(8, 30), 2);
200
+ state.updateArrival(2, timeFromHM(9, 0), 1);
201
+ state.initRound(2);
202
+ assert.strictEqual(state.roundLabels[2]![2], timeFromHM(8, 30));
203
+ });
204
+
205
+ it('is a no-op when no stop changed in the previous round', () => {
206
+ const state = new RangeRaptorState(
207
+ MAX_ROUNDS,
208
+ NB_STOPS,
209
+ timeFromHM(12, 0),
210
+ );
211
+ state.setCurrentRun(RoutingState.fromTestData({ nbStops: NB_STOPS }));
212
+ state.initRound(1);
213
+ for (const val of state.roundLabels[1]!) {
214
+ assert.strictEqual(val, UNREACHED_TIME);
215
+ }
216
+ });
217
+
218
+ it('clears the changed-stop list so a subsequent call propagates nothing new', () => {
219
+ const state = new RangeRaptorState(
220
+ MAX_ROUNDS,
221
+ NB_STOPS,
222
+ timeFromHM(12, 0),
223
+ );
224
+ state.setCurrentRun(RoutingState.fromTestData({ nbStops: NB_STOPS }));
225
+ state.updateArrival(2, timeFromHM(9, 0), 0);
226
+ state.initRound(1);
227
+ state.initRound(2);
228
+ assert.strictEqual(state.roundLabels[2]![2], UNREACHED_TIME);
229
+ });
230
+ });
231
+
232
+ describe('isDestination', () => {
233
+ it('delegates to the current run', () => {
234
+ const state = new RangeRaptorState(
235
+ MAX_ROUNDS,
236
+ NB_STOPS,
237
+ timeFromHM(12, 0),
238
+ );
239
+ state.setCurrentRun(
240
+ RoutingState.fromTestData({ nbStops: NB_STOPS, destinations: [3] }),
241
+ );
242
+ assert.strictEqual(state.isDestination(3), true);
243
+ assert.strictEqual(state.isDestination(0), false);
244
+ });
245
+ });
246
+ });