Geo_test.res
1open VitestHelpers
2
3// Helper to build a route entry for the 'from' dict
4let makeRoute = (
5 ~id: option<string>=?,
6 ~d: option<float>=?,
7 ~t: option<float>=?,
8 (),
9): Waypoint.route => {
10 id: id,
11 d: d,
12 t: t,
13 bbox: None,
14}
15
16// Helper to build a waypoint
17let makeWaypoint = (
18 ~id: string,
19 ~point: (float, float)=(0.0, 0.0),
20 ~from: option<Dict.t<Waypoint.route>>=None,
21 ~label: string="",
22 ~gid: string="",
23 ~country: string="",
24 ~cons: array<string>=[],
25 ~nonroutable: bool=false,
26 ~error: option<array<string>>=None,
27 (),
28): Waypoint.attributes => {
29 id: id,
30 point: point,
31 from: from,
32 label: label,
33 gid: gid,
34 country: country,
35 cons: cons,
36 nonroutable: nonroutable,
37 error: error,
38}
39
40// Minimal GeoJSON LineString for nearestPointOnLine
41let makeLineGeoJSON = (coords: array<(float, float)>): Geo.GeoJSON.t => {
42 let json = JSON.Encode.object(Dict.fromArray([
43 ("type", JSON.Encode.string("Feature")),
44 ("geometry", JSON.Encode.object(Dict.fromArray([
45 ("type", JSON.Encode.string("LineString")),
46 ("coordinates", coords->Array.map(((lon, lat)) => {
47 JSON.Encode.array([JSON.Encode.float(lon), JSON.Encode.float(lat)])
48 })->JSON.Encode.array),
49 ]))),
50 ("properties", JSON.Encode.object(Dict.make())),
51 ]))
52 json->Obj.magic
53}
54
55// GeoJSON LineString with distance and time properties for getTime tests
56let makeLineGeoJSONWithProps = (
57 coords: array<(float, float)>,
58 ~distance: float,
59 ~time: float,
60): Geo.GeoJSON.t => {
61 let json = JSON.Encode.object(Dict.fromArray([
62 ("type", JSON.Encode.string("Feature")),
63 ("geometry", JSON.Encode.object(Dict.fromArray([
64 ("type", JSON.Encode.string("LineString")),
65 ("coordinates", coords->Array.map(((lon, lat)) => {
66 JSON.Encode.array([JSON.Encode.float(lon), JSON.Encode.float(lat)])
67 })->JSON.Encode.array),
68 ]))),
69 ("properties", JSON.Encode.object(Dict.fromArray([
70 ("distance", JSON.Encode.float(distance)),
71 ("time", JSON.Encode.float(time)),
72 ]))),
73 ]))
74 json->Obj.magic
75}
76
77// Raw helper to extract Map entries as array of [key, value]
78let mapEntries: Map.t<'k, 'v> => array<('k, 'v)> = %raw(`(map) => Array.from(map.entries())`)
79
80// Compare two string arrays for equality
81let stringArraysEqual = (a: array<string>, b: array<string>): bool => {
82 if Array.length(a) != Array.length(b) {
83 false
84 } else {
85 let rec check = (idx: int) => {
86 if idx >= Array.length(a) {
87 true
88 } else if a->Array.getUnsafe(idx) == b->Array.getUnsafe(idx) {
89 check(idx + 1)
90 } else {
91 false
92 }
93 }
94 check(0)
95 }
96}
97
98describe("Geo.getDistance", () => {
99 test("linear path: calculates fromPreviousWaypoint and single fromStart path", ctx => {
100 // Route A->B has hash "route-ab", distance 100 km
101 // Route B->C has hash "route-bc", distance 50 km
102 let waypoints = [
103 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
104 makeWaypoint(
105 ~id="B",
106 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
107 (),
108 ),
109 makeWaypoint(
110 ~id="C",
111 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
112 (),
113 ),
114 ]
115
116 // GeoJSON for B->C: straight line from (0,0) to (1,0) with known distance ~111km at equator
117 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=0.0)
118
119 // Hover at the midpoint of B->C
120 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
121
122 // fromPreviousWaypoint should be roughly half the line distance
123 expect(ctx, result.fromPreviousWaypoint)->toBeGreaterThan(0.0)
124
125 // Should have exactly one path from origin to source of current segment (B)
126 let entries = mapEntries(result.fromStart)
127 expect(ctx, entries->Array.length)->toBe(1)
128
129 switch entries->Array.get(0) {
130 | Some((path, dist)) => {
131 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
132 expect(ctx, dist)->toBe(100.0 +. result.fromPreviousWaypoint)
133 }
134 | None => expect(ctx, "should have one entry")->toBe("")
135 }
136
137 // Verify toNextWaypoint is positive and less than total segment distance
138 expect(ctx, result.toNextWaypoint)->toBeGreaterThan(0.0)
139 // Total segment is roughly 111km (at equator from (0,0) to (1,0))
140 expect(ctx, result.toNextWaypoint)->toBeLessThan(111.2)
141 // fromPreviousWaypoint + toNextWaypoint should roughly equal total
142 let total = result.fromPreviousWaypoint +. result.toNextWaypoint
143 expect(ctx, total)->toBeGreaterThan(111.0)
144 expect(ctx, total)->toBeLessThan(111.3)
145 })
146
147 test("toNextWaypoint calculates remaining distance correctly", ctx => {
148 // Route B->C is 50 km total
149 let waypoints = [
150 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
151 makeWaypoint(
152 ~id="B",
153 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
154 (),
155 ),
156 makeWaypoint(
157 ~id="C",
158 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
159 (),
160 ),
161 ]
162
163 // Create GeoJSON with known total distance (111.19 km at equator)
164 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=0.0)
165
166 // Hover at midpoint (0.5, 0.0)
167 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
168
169 // At midpoint, fromPreviousWaypoint and toNextWaypoint should be approximately equal
170 let diff = Math.abs(result.fromPreviousWaypoint -. result.toNextWaypoint)
171 expect(ctx, diff)->toBeLessThan(1.0) // Within 1km
172
173 // Sum should equal total segment distance
174 let total = result.fromPreviousWaypoint +. result.toNextWaypoint
175 expect(ctx, total)->toBeGreaterThan(111.0)
176 expect(ctx, total)->toBeLessThan(111.3)
177 })
178
179 test("merge path: two routes to source waypoint", ctx => {
180 // A -> B (route-ab, 100 km)
181 // B -> C (route-bc, 30 km)
182 // C -> D (route-cd, 20 km)
183 // B -> D (route-bd, 60 km)
184 // Hover on D -> E (route-de, 40 km)
185 let waypoints = [
186 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
187 makeWaypoint(
188 ~id="B",
189 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
190 (),
191 ),
192 makeWaypoint(
193 ~id="C",
194 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=30.0, ()))])),
195 (),
196 ),
197 makeWaypoint(
198 ~id="D",
199 ~from=Some(Dict.fromArray([
200 ("B", makeRoute(~id="route-bd", ~d=60.0, ())),
201 ("C", makeRoute(~id="route-cd", ~d=20.0, ())),
202 ])),
203 (),
204 ),
205 makeWaypoint(
206 ~id="E",
207 ~from=Some(Dict.fromArray([("D", makeRoute(~id="route-de", ~d=40.0, ()))])),
208 (),
209 ),
210 ]
211
212 let geojson = makeLineGeoJSON([(0.0, 0.0), (0.5, 0.0)])
213 let result = Geo.getDistance((0.25, 0.0), "route-de", waypoints, geojson)
214
215 let entries = mapEntries(result.fromStart)
216 expect(ctx, entries->Array.length)->toBe(2)
217
218 // Verify both expected paths exist with correct distances
219 let findEntry = (expectedPath, expectedDist) => {
220 entries->Array.find(((path, dist)) => {
221 stringArraysEqual(path, expectedPath) &&
222 Math.abs(dist -. expectedDist) < 0.0001
223 })
224 }
225
226 expect(ctx, findEntry(["A", "B", "D"], 160.0 +. result.fromPreviousWaypoint)->Option.isSome)->toBe(true)
227 expect(ctx, findEntry(["A", "B", "C", "D"], 150.0 +. result.fromPreviousWaypoint)->Option.isSome)->toBe(true)
228 })
229
230 test("cycle detection: skips cyclic paths", ctx => {
231 // A -> B -> C -> B (cycle)
232 // B also has A as predecessor (the legitimate one)
233 let waypoints = [
234 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
235 makeWaypoint(
236 ~id="B",
237 ~from=Some(Dict.fromArray([
238 ("A", makeRoute(~id="route-ab", ~d=100.0, ())),
239 ("C", makeRoute(~id="route-cb", ~d=50.0, ())), // cycle back from C
240 ])),
241 (),
242 ),
243 makeWaypoint(
244 ~id="C",
245 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=30.0, ()))])),
246 (),
247 ),
248 makeWaypoint(
249 ~id="D",
250 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bd", ~d=20.0, ()))])),
251 (),
252 ),
253 ]
254
255 let geojson = makeLineGeoJSON([(0.0, 0.0), (0.5, 0.0)])
256 let result = Geo.getDistance((0.25, 0.0), "route-bd", waypoints, geojson)
257
258 let entries = mapEntries(result.fromStart)
259 // Only path A->B->D should exist; A->B->C->B->D is cyclic and skipped
260 expect(ctx, entries->Array.length)->toBe(1)
261
262 let hasValidPath = switch entries->Array.find(((path, _)) => {
263 stringArraysEqual(path, ["A", "B"])
264 }) {
265 | Some(_) => true
266 | None => false
267 }
268 expect(ctx, hasValidPath)->toBe(true)
269 })
270
271 test("missing route hash: returns zero/empty result", ctx => {
272 let waypoints = [
273 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
274 makeWaypoint(
275 ~id="B",
276 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
277 (),
278 ),
279 ]
280
281 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
282 let result = Geo.getDistance((0.5, 0.0), "nonexistent-route", waypoints, geojson)
283
284 expect(ctx, result.fromPreviousWaypoint)->toBe(0.0)
285 expect(ctx, result.fromStart->Map.size)->toBe(0)
286 })
287
288 test("origin only: hover on first segment", ctx => {
289 // A is origin, but there's no actual route yet (only one waypoint)
290 // This shouldn't crash even though no route matches
291 let waypoints = [
292 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
293 ]
294
295 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
296 let result = Geo.getDistance((0.5, 0.0), "any-route", waypoints, geojson)
297
298 expect(ctx, result.fromPreviousWaypoint)->toBe(0.0)
299 expect(ctx, result.fromStart->Map.size)->toBe(0)
300 })
301
302 test("route keys with :saa suffix (auto-added routes)", ctx => {
303 // Routes auto-added by the server have :saa suffix
304 // A -> B (route-ab with :saa suffix)
305 // B -> C (route-bc with :saa suffix)
306 let waypoints = [
307 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
308 makeWaypoint(
309 ~id="B",
310 ~from=Some(Dict.fromArray([("A:saa", makeRoute(~id="route-ab", ~d=100.0, ()))])),
311 (),
312 ),
313 makeWaypoint(
314 ~id="C",
315 ~from=Some(Dict.fromArray([("B:saa", makeRoute(~id="route-bc", ~d=50.0, ()))])),
316 (),
317 ),
318 ]
319
320 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
321 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
322
323 // Should still find the path correctly despite :saa suffix
324 expect(ctx, result.fromPreviousWaypoint)->toBeGreaterThan(0.0)
325
326 let entries = mapEntries(result.fromStart)
327 expect(ctx, entries->Array.length)->toBe(1)
328
329 switch entries->Array.get(0) {
330 | Some((path, dist)) => {
331 // Path should contain waypoint IDs without suffixes
332 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
333 expect(ctx, dist)->toBe(100.0 +. result.fromPreviousWaypoint)
334 }
335 | None => expect(ctx, "should have one entry")->toBe("")
336 }
337 })
338
339 test("route keys with adventure constraint suffix", ctx => {
340 // Routes with adventure constraints: wp1:adv(80)
341 // A -> B (route-ab with adv(80) constraint)
342 let waypoints = [
343 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
344 makeWaypoint(
345 ~id="B",
346 ~from=Some(Dict.fromArray([("A:adv(80)", makeRoute(~id="route-ab", ~d=120.0, ()))])),
347 (),
348 ),
349 makeWaypoint(
350 ~id="C",
351 ~from=Some(Dict.fromArray([("B:adv(80)", makeRoute(~id="route-bc", ~d=60.0, ()))])),
352 (),
353 ),
354 ]
355
356 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
357 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
358
359 let entries = mapEntries(result.fromStart)
360 expect(ctx, entries->Array.length)->toBe(1)
361
362 switch entries->Array.get(0) {
363 | Some((path, dist)) => {
364 // Path should contain base waypoint IDs without constraint suffixes
365 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
366 expect(ctx, dist)->toBe(120.0 +. result.fromPreviousWaypoint)
367 }
368 | None => expect(ctx, "should have one entry")->toBe("")
369 }
370 })
371
372 test("route keys with multiple constraints", ctx => {
373 // Routes with multiple constraints: wp1:adv(80):max(100):saa
374 let waypoints = [
375 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
376 makeWaypoint(
377 ~id="B",
378 ~from=Some(Dict.fromArray([
379 ("A:adv(80):max(100):saa", makeRoute(~id="route-ab", ~d=110.0, ()))
380 ])),
381 (),
382 ),
383 makeWaypoint(
384 ~id="C",
385 ~from=Some(Dict.fromArray([
386 ("B:adv(80):max(100):saa", makeRoute(~id="route-bc", ~d=55.0, ()))
387 ])),
388 (),
389 ),
390 ]
391
392 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
393 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
394
395 let entries = mapEntries(result.fromStart)
396 expect(ctx, entries->Array.length)->toBe(1)
397
398 switch entries->Array.get(0) {
399 | Some((path, dist)) => {
400 // Should extract just "A" and "B" from the complex keys
401 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
402 expect(ctx, dist)->toBe(110.0 +. result.fromPreviousWaypoint)
403 }
404 | None => expect(ctx, "should have one entry")->toBe("")
405 }
406 })
407
408 test("mixed route keys with and without constraints", ctx => {
409 // Mix of simple keys and keys with constraints
410 // A -> B (simple key)
411 // B -> C (with :saa suffix)
412 // C -> D (with multiple constraints)
413 let waypoints = [
414 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
415 makeWaypoint(
416 ~id="B",
417 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
418 (),
419 ),
420 makeWaypoint(
421 ~id="C",
422 ~from=Some(Dict.fromArray([("B:saa", makeRoute(~id="route-bc", ~d=50.0, ()))])),
423 (),
424 ),
425 makeWaypoint(
426 ~id="D",
427 ~from=Some(Dict.fromArray([
428 ("C:adv(60):saa", makeRoute(~id="route-cd", ~d=30.0, ()))
429 ])),
430 (),
431 ),
432 ]
433
434 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
435 let result = Geo.getDistance((0.5, 0.0), "route-cd", waypoints, geojson)
436
437 let entries = mapEntries(result.fromStart)
438 expect(ctx, entries->Array.length)->toBe(1)
439
440 switch entries->Array.get(0) {
441 | Some((path, dist)) => {
442 // Should handle all variations correctly
443 expect(ctx, stringArraysEqual(path, ["A", "B", "C"]))->toBe(true)
444 expect(ctx, dist)->toBe(150.0 +. result.fromPreviousWaypoint)
445 }
446 | None => expect(ctx, "should have one entry")->toBe("")
447 }
448 })
449
450 test("returns correct previousID and nextID", ctx => {
451 // Route A->B->C
452 let waypoints = [
453 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
454 makeWaypoint(
455 ~id="B",
456 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
457 (),
458 ),
459 makeWaypoint(
460 ~id="C",
461 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
462 (),
463 ),
464 ]
465
466 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
467
468 // Hover on route B->C
469 let result = Geo.getDistance((0.5, 0.0), "route-bc", waypoints, geojson)
470
471 // previousID should be "B" (source of the route)
472 expect(ctx, result.previousID)->toEqual(Some("B"))
473 // nextID should be "C" (destination of the route)
474 expect(ctx, result.nextID)->toEqual(Some("C"))
475 })
476
477 test("returns None for previousID and nextID when route not found", ctx => {
478 let waypoints = [
479 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
480 makeWaypoint(
481 ~id="B",
482 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
483 (),
484 ),
485 ]
486
487 let geojson = makeLineGeoJSON([(0.0, 0.0), (1.0, 0.0)])
488
489 // Hover on non-existent route
490 let result = Geo.getDistance((0.5, 0.0), "nonexistent-route", waypoints, geojson)
491
492 expect(ctx, result.previousID)->toEqual(None)
493 expect(ctx, result.nextID)->toEqual(None)
494 })
495})
496
497describe("Geo.getTime", () => {
498 test("linear path: calculates fromPreviousWaypoint and single fromStart path", ctx => {
499 // Route A->B has hash "route-ab", distance 100 km, time 3600s (1 hour)
500 // Route B->C has hash "route-bc", distance 50 km, time 1800s (30 min)
501 let waypoints = [
502 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
503 makeWaypoint(
504 ~id="B",
505 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
506 (),
507 ),
508 makeWaypoint(
509 ~id="C",
510 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ~t=1800.0, ()))])),
511 (),
512 ),
513 ]
514
515 // GeoJSON for B->C: straight line from (0,0) to (1,0)
516 // Total distance ~111km (at equator), total time 1800s
517 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=1800.0)
518
519 // Hover at the midpoint of B->C
520 let result = Geo.getTime((0.5, 0.0), "route-bc", waypoints, geojson)
521
522 // fromPreviousWaypoint should be roughly half the total time
523 expect(ctx, result.fromPreviousWaypoint)->toBeGreaterThan(0.0)
524 expect(ctx, result.fromPreviousWaypoint)->toBeLessThan(1800.0)
525
526 // Should have exactly one path from origin to source of current segment (B)
527 let entries = mapEntries(result.fromStart)
528 expect(ctx, entries->Array.length)->toBe(1)
529
530 switch entries->Array.get(0) {
531 | Some((path, time)) => {
532 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
533 // Total time = A->B time (3600s) + interpolated time on B->C
534 expect(ctx, time)->toBe(3600.0 +. result.fromPreviousWaypoint)
535 }
536 | None => expect(ctx, "should have one entry")->toBe("")
537 }
538
539 // Verify toNextWaypoint time is positive and less than total segment time
540 expect(ctx, result.toNextWaypoint)->toBeGreaterThan(0.0)
541 expect(ctx, result.toNextWaypoint)->toBeLessThan(1800.0)
542 // fromPreviousWaypoint + toNextWaypoint should equal total time
543 let totalTime = result.fromPreviousWaypoint +. result.toNextWaypoint
544 expect(ctx, Math.abs(totalTime -. 1800.0))->toBeLessThan(0.1)
545 })
546
547 test("toNextWaypoint calculates remaining time correctly", ctx => {
548 let waypoints = [
549 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
550 makeWaypoint(
551 ~id="B",
552 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
553 (),
554 ),
555 makeWaypoint(
556 ~id="C",
557 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ~t=1800.0, ()))])),
558 (),
559 ),
560 ]
561
562 // GeoJSON with known distance and time
563 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=1800.0)
564
565 // Hover at midpoint
566 let result = Geo.getTime((0.5, 0.0), "route-bc", waypoints, geojson)
567
568 // At midpoint, fromPreviousWaypoint and toNextWaypoint should be approximately equal
569 let diff = Math.abs(result.fromPreviousWaypoint -. result.toNextWaypoint)
570 expect(ctx, diff)->toBeLessThan(10.0) // Within 10 seconds
571
572 // Sum should equal total segment time
573 let total = result.fromPreviousWaypoint +. result.toNextWaypoint
574 expect(ctx, Math.abs(total -. 1800.0))->toBeLessThan(0.1)
575 })
576
577 test("merge path: two routes to source waypoint with different times", ctx => {
578 // A -> B (route-ab, 100 km, 3600s)
579 // B -> C (route-bc, 30 km, 1200s)
580 // C -> D (route-cd, 20 km, 800s)
581 // B -> D (route-bd, 60 km, 2400s) - faster alternative
582 // Hover on D -> E (route-de, 40 km, 1600s)
583 let waypoints = [
584 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
585 makeWaypoint(
586 ~id="B",
587 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
588 (),
589 ),
590 makeWaypoint(
591 ~id="C",
592 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=30.0, ~t=1200.0, ()))])),
593 (),
594 ),
595 makeWaypoint(
596 ~id="D",
597 ~from=Some(Dict.fromArray([
598 ("B", makeRoute(~id="route-bd", ~d=60.0, ~t=2400.0, ())),
599 ("C", makeRoute(~id="route-cd", ~d=20.0, ~t=800.0, ())),
600 ])),
601 (),
602 ),
603 makeWaypoint(
604 ~id="E",
605 ~from=Some(Dict.fromArray([("D", makeRoute(~id="route-de", ~d=40.0, ~t=1600.0, ()))])),
606 (),
607 ),
608 ]
609
610 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (0.5, 0.0)], ~distance=55.6, ~time=1600.0)
611
612 let result = Geo.getTime((0.25, 0.0), "route-de", waypoints, geojson)
613
614 let entries = mapEntries(result.fromStart)
615 expect(ctx, entries->Array.length)->toBe(2)
616
617 // Verify both expected paths exist with correct times
618 // Path 1: A->B->D->(partial E): 3600 + 2400 + interpolated
619 // Path 2: A->B->C->D->(partial E): 3600 + 1200 + 800 + interpolated
620 let findEntry = (expectedPath, expectedTime) => {
621 entries->Array.find(((path, time)) => {
622 stringArraysEqual(path, expectedPath) &&
623 Math.abs(time -. expectedTime) < 0.0001
624 })
625 }
626
627 expect(ctx, findEntry(["A", "B", "D"], 6000.0 +. result.fromPreviousWaypoint)->Option.isSome)->toBe(
628 true,
629 )
630 expect(ctx, findEntry(["A", "B", "C", "D"], 5600.0 +. result.fromPreviousWaypoint)->Option.isSome)->toBe(
631 true,
632 )
633 })
634
635 test("returns correct previousID and nextID", ctx => {
636 // Route A->B->C
637 let waypoints = [
638 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
639 makeWaypoint(
640 ~id="B",
641 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
642 (),
643 ),
644 makeWaypoint(
645 ~id="C",
646 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ~t=1800.0, ()))])),
647 (),
648 ),
649 ]
650
651 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=1800.0)
652
653 // Hover on route B->C
654 let result = Geo.getTime((0.5, 0.0), "route-bc", waypoints, geojson)
655
656 // previousID should be "B" (source of the route)
657 expect(ctx, result.previousID)->toEqual(Some("B"))
658 // nextID should be "C" (destination of the route)
659 expect(ctx, result.nextID)->toEqual(Some("C"))
660 })
661
662 test("zero total distance: returns zero time for current segment", ctx => {
663 let waypoints = [
664 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
665 makeWaypoint(
666 ~id="B",
667 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
668 (),
669 ),
670 ]
671
672 // GeoJSON with zero distance to test division by zero handling
673 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=0.0, ~time=1800.0)
674 let result = Geo.getTime((0.5, 0.0), "route-ab", waypoints, geojson)
675
676 // Should return 0 for current segment to avoid division by zero
677 expect(ctx, result.fromPreviousWaypoint)->toBe(0.0)
678 })
679
680 test("missing route hash: returns zero/empty result", ctx => {
681 let waypoints = [
682 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
683 makeWaypoint(
684 ~id="B",
685 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
686 (),
687 ),
688 ]
689
690 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=100.0, ~time=3600.0)
691 let result = Geo.getTime((0.5, 0.0), "nonexistent-route", waypoints, geojson)
692
693 expect(ctx, result.fromPreviousWaypoint)->toBe(0.0)
694 expect(ctx, result.fromStart->Map.size)->toBe(0)
695 })
696
697 test("route with time of None: uses 0 as default", ctx => {
698 // Route without time field should default to 0
699 let waypoints = [
700 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
701 makeWaypoint(
702 ~id="B",
703 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
704 (),
705 ),
706 makeWaypoint(
707 ~id="C",
708 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
709 (),
710 ),
711 ]
712
713 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=1800.0)
714
715 let result = Geo.getTime((0.5, 0.0), "route-bc", waypoints, geojson)
716
717 let entries = mapEntries(result.fromStart)
718 switch entries->Array.get(0) {
719 | Some((path, time)) => {
720 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
721 // Time should be just A->B time (3600s) + interpolated, since B->C has no time
722 expect(ctx, time)->toBe(3600.0 +. result.fromPreviousWaypoint)
723 }
724 | None => expect(ctx, "should have one entry")->toBe("")
725 }
726 })
727
728 test("route keys with constraint suffixes work correctly", ctx => {
729 // Routes with :saa suffix
730 let waypoints = [
731 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
732 makeWaypoint(
733 ~id="B",
734 ~from=Some(Dict.fromArray([("A:saa", makeRoute(~id="route-ab", ~d=100.0, ~t=3600.0, ()))])),
735 (),
736 ),
737 makeWaypoint(
738 ~id="C",
739 ~from=Some(Dict.fromArray([("B:saa", makeRoute(~id="route-bc", ~d=50.0, ~t=1800.0, ()))])),
740 (),
741 ),
742 ]
743
744 let geojson = makeLineGeoJSONWithProps([(0.0, 0.0), (1.0, 0.0)], ~distance=111.19, ~time=1800.0)
745
746 let result = Geo.getTime((0.5, 0.0), "route-bc", waypoints, geojson)
747
748 let entries = mapEntries(result.fromStart)
749 expect(ctx, entries->Array.length)->toBe(1)
750
751 switch entries->Array.get(0) {
752 | Some((path, time)) => {
753 expect(ctx, stringArraysEqual(path, ["A", "B"]))->toBe(true)
754 expect(ctx, time)->toBe(3600.0 +. result.fromPreviousWaypoint)
755 }
756 | None => expect(ctx, "should have one entry")->toBe("")
757 }
758 })
759})
760
761describe("Geo.getDistanceMatrix", () => {
762 test("linear path A -> B -> C: builds correct distance matrix", ctx => {
763 // Simple linear path: A (origin) -> B (100km) -> C (50km)
764 let waypoints = [
765 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
766 makeWaypoint(
767 ~id="B",
768 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
769 (),
770 ),
771 makeWaypoint(
772 ~id="C",
773 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
774 (),
775 ),
776 ]
777
778 let matrix = Geo.getDistanceMatrix(waypoints)
779 let entries = mapEntries(matrix)
780
781 // Should have exactly 2 paths: [A,B] and [A,B,C]
782 expect(ctx, entries->Array.length)->toBe(2)
783
784 // Verify [A,B] -> 100
785 let pathAB = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "B"]))
786 switch pathAB {
787 | Some((_, dist)) => expect(ctx, dist)->toBe(100.0)
788 | None => expect(ctx, "should have path [A,B]")->toBe("")
789 }
790
791 // Verify [A,B,C] -> 150 (100 + 50)
792 let pathABC = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "B", "C"]))
793 switch pathABC {
794 | Some((_, dist)) => expect(ctx, dist)->toBe(150.0)
795 | None => expect(ctx, "should have path [A,B,C]")->toBe("")
796 }
797 })
798
799 test("multiple paths to same destination: includes all routes", ctx => {
800 // Two ways to get to C:
801 // Path 1: A -> B (100km) -> C (50km) = 150km
802 // Path 2: A -> D (80km) -> C (30km) = 110km
803 let waypoints = [
804 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
805 makeWaypoint(
806 ~id="B",
807 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
808 (),
809 ),
810 makeWaypoint(
811 ~id="D",
812 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ad", ~d=80.0, ()))])),
813 (),
814 ),
815 makeWaypoint(
816 ~id="C",
817 ~from=Some(Dict.fromArray([
818 ("B", makeRoute(~id="route-bc", ~d=50.0, ())),
819 ("D", makeRoute(~id="route-dc", ~d=30.0, ())),
820 ])),
821 (),
822 ),
823 ]
824
825 let matrix = Geo.getDistanceMatrix(waypoints)
826 let entries = mapEntries(matrix)
827
828 // Should have 4 paths: [A,B], [A,D], [A,B,C], [A,D,C]
829 expect(ctx, entries->Array.length)->toBe(4)
830
831 // Verify [A,B] -> 100
832 let pathAB = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "B"]))
833 switch pathAB {
834 | Some((_, dist)) => expect(ctx, dist)->toBe(100.0)
835 | None => expect(ctx, "should have path [A,B]")->toBe("")
836 }
837
838 // Verify [A,D] -> 80
839 let pathAD = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "D"]))
840 switch pathAD {
841 | Some((_, dist)) => expect(ctx, dist)->toBe(80.0)
842 | None => expect(ctx, "should have path [A,D]")->toBe("")
843 }
844
845 // Verify [A,B,C] -> 150 (100 + 50)
846 let pathABC = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "B", "C"]))
847 switch pathABC {
848 | Some((_, dist)) => expect(ctx, dist)->toBe(150.0)
849 | None => expect(ctx, "should have path [A,B,C]")->toBe("")
850 }
851
852 // Verify [A,D,C] -> 110 (80 + 30)
853 let pathADC = entries->Array.find(((path, _)) => stringArraysEqual(path, ["A", "D", "C"]))
854 switch pathADC {
855 | Some((_, dist)) => expect(ctx, dist)->toBe(110.0)
856 | None => expect(ctx, "should have path [A,D,C]")->toBe("")
857 }
858 })
859
860 test("non-routable waypoint: excluded from matrix", ctx => {
861 // Path: A -> B (100km) -> C (50km)
862 // B is marked as non-routable - should not appear in matrix
863 // C cannot be reached because path goes through B
864 let waypoints = [
865 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
866 makeWaypoint(
867 ~id="B",
868 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=100.0, ()))])),
869 ~nonroutable=true,
870 (),
871 ),
872 makeWaypoint(
873 ~id="C",
874 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=50.0, ()))])),
875 (),
876 ),
877 ]
878
879 let matrix = Geo.getDistanceMatrix(waypoints)
880 let entries = mapEntries(matrix)
881
882 // Matrix should be empty - no paths through non-routable B
883 expect(ctx, entries->Array.length)->toBe(0)
884 })
885
886 test("cycle detection: avoids infinite loops", ctx => {
887 // A -> B -> C -> B (cycle back to B)
888 // Should only traverse valid non-cyclic paths
889 let waypoints = [
890 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
891 makeWaypoint(
892 ~id="B",
893 ~from=Some(Dict.fromArray([
894 ("A", makeRoute(~id="route-ab", ~d=100.0, ())),
895 ("C", makeRoute(~id="route-cb", ~d=50.0, ())), // cycle back from C
896 ])),
897 (),
898 ),
899 makeWaypoint(
900 ~id="C",
901 ~from=Some(Dict.fromArray([("B", makeRoute(~id="route-bc", ~d=30.0, ()))])),
902 (),
903 ),
904 ]
905
906 let matrix = Geo.getDistanceMatrix(waypoints)
907 let entries = mapEntries(matrix)
908
909 // Should have [A,B] and [A,B,C], but not cyclic paths
910 expect(ctx, entries->Array.length)->toBe(2)
911
912 // Verify [A,B] exists
913 let hasAB = entries->Array.some(((path, _)) => stringArraysEqual(path, ["A", "B"]))
914 expect(ctx, hasAB)->toBe(true)
915
916 // Verify [A,B,C] exists
917 let hasABC = entries->Array.some(((path, _)) => stringArraysEqual(path, ["A", "B", "C"]))
918 expect(ctx, hasABC)->toBe(true)
919
920 // Verify no cyclic paths exist (path length > 3 would indicate cycling)
921 let hasLongPath = entries->Array.some(((path, _)) => Array.length(path) > 3)
922 expect(ctx, hasLongPath)->toBe(false)
923 })
924
925 test("no origin: returns empty matrix", ctx => {
926 // All waypoints have non-empty from dicts - no origin exists
927 let waypoints = [
928 makeWaypoint(
929 ~id="A",
930 ~from=Some(Dict.fromArray([("X", makeRoute(~id="route-xa", ~d=100.0, ()))])),
931 (),
932 ),
933 makeWaypoint(
934 ~id="B",
935 ~from=Some(Dict.fromArray([("A", makeRoute(~id="route-ab", ~d=50.0, ()))])),
936 (),
937 ),
938 ]
939
940 let matrix = Geo.getDistanceMatrix(waypoints)
941 let entries = mapEntries(matrix)
942
943 expect(ctx, entries->Array.length)->toBe(0)
944 })
945
946 test("route keys with suffixes: parses correctly", ctx => {
947 // Routes with :saa suffix in from dict
948 let waypoints = [
949 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
950 makeWaypoint(
951 ~id="B",
952 ~from=Some(Dict.fromArray([("A:saa", makeRoute(~id="route-ab", ~d=100.0, ()))])),
953 (),
954 ),
955 makeWaypoint(
956 ~id="C",
957 ~from=Some(Dict.fromArray([("B:saa", makeRoute(~id="route-bc", ~d=50.0, ()))])),
958 (),
959 ),
960 ]
961
962 let matrix = Geo.getDistanceMatrix(waypoints)
963 let entries = mapEntries(matrix)
964
965 // Should correctly parse waypoint IDs without suffixes
966 expect(ctx, entries->Array.length)->toBe(2)
967
968 let hasAB = entries->Array.some(((path, _)) => stringArraysEqual(path, ["A", "B"]))
969 expect(ctx, hasAB)->toBe(true)
970
971 let hasABC = entries->Array.some(((path, _)) => stringArraysEqual(path, ["A", "B", "C"]))
972 expect(ctx, hasABC)->toBe(true)
973 })
974})
975
976describe("Geo.relativeDistances", () => {
977 test("linear path: calculates correct relative distances", ctx => {
978 // A -> B (100km) -> C (50km)
979 let waypoints = [
980 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
981 makeWaypoint(
982 ~id="B",
983 ~from=Some(Dict.fromArray([("A", makeRoute(~d=100.0, ()))])),
984 (),
985 ),
986 makeWaypoint(
987 ~id="C",
988 ~from=Some(Dict.fromArray([("B", makeRoute(~d=50.0, ()))])),
989 (),
990 ),
991 ]
992
993 let matrix = Geo.getDistanceMatrix(waypoints)
994 let relative = Geo.relativeDistances("B", matrix)
995
996 // From B's perspective: A is -100, B is 0, C is +50
997 expect(ctx, relative->Map.get("A"))->toEqual(Some(-100.0))
998 expect(ctx, relative->Map.get("B"))->toEqual(Some(0.0))
999 expect(ctx, relative->Map.get("C"))->toEqual(Some(50.0))
1000 })
1001
1002 test("reference at origin: all distances positive", ctx => {
1003 // A (origin) -> B (100km) -> C (50km)
1004 let waypoints = [
1005 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
1006 makeWaypoint(
1007 ~id="B",
1008 ~from=Some(Dict.fromArray([("A", makeRoute(~d=100.0, ()))])),
1009 (),
1010 ),
1011 makeWaypoint(
1012 ~id="C",
1013 ~from=Some(Dict.fromArray([("B", makeRoute(~d=50.0, ()))])),
1014 (),
1015 ),
1016 ]
1017
1018 let matrix = Geo.getDistanceMatrix(waypoints)
1019 let relative = Geo.relativeDistances("A", matrix)
1020
1021 // From A's perspective: A is 0, B is +100, C is +150
1022 expect(ctx, relative->Map.get("A"))->toEqual(Some(0.0))
1023 expect(ctx, relative->Map.get("B"))->toEqual(Some(100.0))
1024 expect(ctx, relative->Map.get("C"))->toEqual(Some(150.0))
1025 })
1026
1027 test("reference at destination: all distances negative", ctx => {
1028 // A -> B (100km) -> C (50km), reference is C
1029 let waypoints = [
1030 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
1031 makeWaypoint(
1032 ~id="B",
1033 ~from=Some(Dict.fromArray([("A", makeRoute(~d=100.0, ()))])),
1034 (),
1035 ),
1036 makeWaypoint(
1037 ~id="C",
1038 ~from=Some(Dict.fromArray([("B", makeRoute(~d=50.0, ()))])),
1039 (),
1040 ),
1041 ]
1042
1043 let matrix = Geo.getDistanceMatrix(waypoints)
1044 let relative = Geo.relativeDistances("C", matrix)
1045
1046 // From C's perspective: A is -150, B is -50, C is 0
1047 expect(ctx, relative->Map.get("A"))->toEqual(Some(-150.0))
1048 expect(ctx, relative->Map.get("B"))->toEqual(Some(-50.0))
1049 expect(ctx, relative->Map.get("C"))->toEqual(Some(0.0))
1050 })
1051
1052 test("multiple paths: chooses shortest absolute distance", ctx => {
1053 // Two paths to C:
1054 // Path 1: A -> B (100km) -> C (50km) = 150km total
1055 // Path 2: A -> D (80km) -> C (30km) = 110km total
1056 let waypoints = [
1057 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
1058 makeWaypoint(
1059 ~id="B",
1060 ~from=Some(Dict.fromArray([("A", makeRoute(~d=100.0, ()))])),
1061 (),
1062 ),
1063 makeWaypoint(
1064 ~id="D",
1065 ~from=Some(Dict.fromArray([("A", makeRoute(~d=80.0, ()))])),
1066 (),
1067 ),
1068 makeWaypoint(
1069 ~id="C",
1070 ~from=Some(Dict.fromArray([
1071 ("B", makeRoute(~d=50.0, ())),
1072 ("D", makeRoute(~d=30.0, ())),
1073 ])),
1074 (),
1075 ),
1076 ]
1077
1078 let matrix = Geo.getDistanceMatrix(waypoints)
1079
1080 // From C's perspective using path C->D->A (shorter than C->B->A)
1081 let relativeToC = Geo.relativeDistances("C", matrix)
1082
1083 // A via D->C: -110, via B->C: -150, shortest is -110
1084 expect(ctx, relativeToC->Map.get("A"))->toEqual(Some(-110.0))
1085 // B is only reachable via longer path: -50 (150 - 100 from origin)
1086 expect(ctx, relativeToC->Map.get("B"))->toEqual(Some(-50.0))
1087 // D via D->C: -30, but wait D->C is from D, so D is at -30 from C
1088 expect(ctx, relativeToC->Map.get("D"))->toEqual(Some(-30.0))
1089 expect(ctx, relativeToC->Map.get("C"))->toEqual(Some(0.0))
1090
1091 // From B's perspective
1092 let relativeToB = Geo.relativeDistances("B", matrix)
1093
1094 // A is at -100 (only one path)
1095 expect(ctx, relativeToB->Map.get("A"))->toEqual(Some(-100.0))
1096 // B is 0
1097 expect(ctx, relativeToB->Map.get("B"))->toEqual(Some(0.0))
1098 // C is +50 via B->C
1099 expect(ctx, relativeToB->Map.get("C"))->toEqual(Some(50.0))
1100 // D is not reachable from B (D branches from A, not from B)
1101 expect(ctx, relativeToB->Map.get("D"))->toEqual(None)
1102 })
1103
1104 test("reference not in matrix: returns empty map", ctx => {
1105 let waypoints = [
1106 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
1107 makeWaypoint(
1108 ~id="B",
1109 ~from=Some(Dict.fromArray([("A", makeRoute(~d=100.0, ()))])),
1110 (),
1111 ),
1112 ]
1113
1114 let matrix = Geo.getDistanceMatrix(waypoints)
1115 // Try to get relative distances for non-existent waypoint "Z"
1116 let relative = Geo.relativeDistances("Z", matrix)
1117
1118 expect(ctx, relative->Map.size)->toBe(0)
1119 })
1120
1121 test("complex branching: correct relative distances", ctx => {
1122 // More complex graph:
1123 // A -> B (10) -> C (20) -> E (30)
1124 // \\-> D (5) ->/
1125 // A->B->C->E = 60
1126 // A->B->D->E = 45 (shorter)
1127 let waypoints = [
1128 makeWaypoint(~id="A", ~from=Some(Dict.fromArray([])), ()),
1129 makeWaypoint(
1130 ~id="B",
1131 ~from=Some(Dict.fromArray([("A", makeRoute(~d=10.0, ()))])),
1132 (),
1133 ),
1134 makeWaypoint(
1135 ~id="C",
1136 ~from=Some(Dict.fromArray([("B", makeRoute(~d=20.0, ()))])),
1137 (),
1138 ),
1139 makeWaypoint(
1140 ~id="D",
1141 ~from=Some(Dict.fromArray([("B", makeRoute(~d=5.0, ()))])),
1142 (),
1143 ),
1144 makeWaypoint(
1145 ~id="E",
1146 ~from=Some(Dict.fromArray([
1147 ("C", makeRoute(~d=30.0, ())),
1148 ("D", makeRoute(~d=30.0, ())), // D->E is 30, same as C->E
1149 ])),
1150 (),
1151 ),
1152 ]
1153
1154 let matrix = Geo.getDistanceMatrix(waypoints)
1155
1156 // From E's perspective
1157 let relativeToE = Geo.relativeDistances("E", matrix)
1158
1159 // A via B-D-E (10+5+30=45): -45, via B-C-E (10+20+30=60): -60, shortest is -45
1160 expect(ctx, relativeToE->Map.get("A"))->toEqual(Some(-45.0))
1161 // B via B-D-E: -35 (45-10), via B-C-E: -50, shortest is -35
1162 expect(ctx, relativeToE->Map.get("B"))->toEqual(Some(-35.0))
1163 // C is at -30 (E - 30 = 60 - 30 = 30, but C is before E so -30)
1164 expect(ctx, relativeToE->Map.get("C"))->toEqual(Some(-30.0))
1165 // D is at -30 (E - 30 = 45 - 15 = 30, but D is before E so -30)
1166 expect(ctx, relativeToE->Map.get("D"))->toEqual(Some(-30.0))
1167 // E is 0
1168 expect(ctx, relativeToE->Map.get("E"))->toEqual(Some(0.0))
1169
1170 // From C's perspective
1171 let relativeToC = Geo.relativeDistances("C", matrix)
1172
1173 // A via B-C: -30 (30 from origin)
1174 expect(ctx, relativeToC->Map.get("A"))->toEqual(Some(-30.0))
1175 // B is at -20
1176 expect(ctx, relativeToC->Map.get("B"))->toEqual(Some(-20.0))
1177 // C is 0
1178 expect(ctx, relativeToC->Map.get("C"))->toEqual(Some(0.0))
1179 // D is not reachable from C (D branches from B, not from C)
1180 expect(ctx, relativeToC->Map.get("D"))->toEqual(None)
1181 // E is +30
1182 expect(ctx, relativeToC->Map.get("E"))->toEqual(Some(30.0))
1183 })
1184})