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})