import { describe, it, expect, vi, afterEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '../../../tests/helpers/msw/server' import { pluginsApi, type PluginRouteResult } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { calculateRoute, calculateRouteWithLegs, calculateSegments, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, parsePluginProfile, withHotelBookends, calculateAlternatives, avoidedClasses, routeEngineFor, sameRoad, furthestFrom, } from './RouteCalculator' // Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org // demo ignored the profile in the URL, so walking routes followed the road network. const FOSSGIS = { driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving', walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot', cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike', } const buildOsrmRouteResponse = (distance = 5000, duration = 360) => ({ code: 'Ok', routes: [ { geometry: { coordinates: [[2.3522, 48.8566], [2.3600, 48.8600]] }, distance, duration, legs: [{ distance, duration }], }, ], }) const wp1 = { lat: 48.8566, lng: 2.3522 } const wp2 = { lat: 48.8600, lng: 2.3600 } // ── calculateRoute ───────────────────────────────────────────────────────────── describe('calculateRoute', () => { it('FE-COMP-ROUTECALCULATOR-001: throws when fewer than 2 waypoints', async () => { await expect(calculateRoute([wp1])).rejects.toThrow('At least 2 waypoints required') }) it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse()) ) ) const result = await calculateRoute([wp1, wp2]) expect(result.coordinates).toEqual([[48.8566, 2.3522], [48.8600, 2.3600]]) }) it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse(1500, 360)) ) ) const result = await calculateRoute([wp1, wp2]) expect(result.distanceText).toBe('1.5 km') }) it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse(800, 360)) ) ) const result = await calculateRoute([wp1, wp2]) expect(result.distanceText).toBe('800 m') }) it('FE-COMP-ROUTECALCULATOR-005: walking profile overrides duration with distance-based calculation', async () => { const distance = 5000 const osrmDuration = 999 server.use( http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration)) ) ) const result = await calculateRoute([wp1, wp2], 'walking') const expectedDuration = distance / (5000 / 3600) expect(result.duration).toBeCloseTo(expectedDuration) expect(result.duration).not.toBe(osrmDuration) }) it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 500 }) ) ) await expect(calculateRoute([wp1, wp2])).rejects.toThrow('Route could not be calculated') }) it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] }) ) ) await expect(calculateRoute([wp1, wp2])).rejects.toThrow('No route found') }) it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse()) ) ) const controller = new AbortController() controller.abort() await expect(calculateRoute([wp1, wp2], 'driving', { signal: controller.signal })).rejects.toThrow() }) }) // ── calculateSegments ────────────────────────────────────────────────────────── describe('calculateSegments', () => { it('FE-COMP-ROUTECALCULATOR-009: returns empty array for fewer than 2 waypoints', async () => { const result = await calculateSegments([wp1]) expect(result).toEqual([]) }) it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [ { legs: [{ distance: 1000, duration: 120 }], }, ], }) ) ) const result = await calculateSegments([wp1, wp2]) expect(result).toHaveLength(1) const seg = result[0] const expectedMid: [number, number] = [ (wp1.lat + wp2.lat) / 2, (wp1.lng + wp2.lng) / 2, ] expect(seg.mid[0]).toBeCloseTo(expectedMid[0]) expect(seg.mid[1]).toBeCloseTo(expectedMid[1]) expect(seg.drivingText).toBe('2 min') }) }) // ── optimizeRoute ────────────────────────────────────────────────────────────── describe('optimizeRoute', () => { it('FE-COMP-ROUTECALCULATOR-011: returns input unchanged for 2 or fewer places', () => { const places = [wp1, wp2] const result = optimizeRoute(places) expect(result).toHaveLength(2) expect(result).toBe(places) }) it('FE-COMP-ROUTECALCULATOR-012: nearest-neighbor reorders 3 waypoints correctly', () => { // Note: filter uses `p.lat && p.lng`, so avoid zero values const a = { lat: 1, lng: 1 } const b = { lat: 10, lng: 1 } const c = { lat: 2, lng: 1 } const result = optimizeRoute([a, b, c]) // Starting from a(1,1), nearest is c(2,1) (dist=1), then b(10,1) (dist=8) expect(result[0]).toEqual(a) expect(result[1]).toEqual(c) expect(result[2]).toEqual(b) }) it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => { const a = { lat: 10, lng: 1 } const b = { lat: 2, lng: 1 } const c = { lat: 5, lng: 1 } // From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1) const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } }) expect(result).toEqual([b, c, a]) }) it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => { const a = { lat: 2, lng: 1 } const b = { lat: 5, lng: 1 } const c = { lat: 8, lng: 1 } // Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last. const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } }) expect(result).toEqual([a, b, c]) }) it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => { const a = { lat: 10, lng: 1 } const b = { lat: 2, lng: 1 } // Without anchors two stops are returned unchanged; the start anchor orders them by proximity. const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } }) expect(result).toEqual([b, a]) }) it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => { const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf const stops = [ { id: 1, lat: 48.8565, lng: 2.3324 }, { id: 2, lat: 48.8813, lng: 2.3151 }, { id: 3, lat: 48.8796, lng: 2.308 }, { id: 4, lat: 48.8723, lng: 2.2926 }, { id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel ] const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) => Math.hypot(a.lat - b.lat, a.lng - b.lng) const loop = (order: typeof stops) => d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel) const result = optimizeRoute(stops, { start: hotel, end: hotel }) // The optimized loop is no longer than the original order… expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9) // …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel. expect([result[0].id, result[result.length - 1].id]).toContain(5) }) it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => { const a = { lat: 2, lng: 1 } const b = { lat: 5, lng: 1 } const c = { lat: 9, lng: 1 } // a is nearest the end anchor, so the route must finish at a rather than start there. const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } }) expect(result[result.length - 1]).toEqual(a) }) }) // ── generateGoogleMapsUrl ────────────────────────────────────────────────────── describe('generateGoogleMapsUrl', () => { it('FE-COMP-ROUTECALCULATOR-013: returns null for empty places', () => { expect(generateGoogleMapsUrl([])).toBeNull() }) it('FE-COMP-ROUTECALCULATOR-014: single place returns search URL', () => { const result = generateGoogleMapsUrl([{ lat: 48.85, lng: 2.35 }]) expect(result).toBe('https://www.google.com/maps/search/?api=1&query=48.85,2.35') }) it('FE-COMP-ROUTECALCULATOR-015: multiple places returns directions URL', () => { const result = generateGoogleMapsUrl([ { lat: 48.85, lng: 2.35 }, { lat: 48.86, lng: 2.36 }, ]) expect(result).toMatch(/^https:\/\/www\.google\.com\/maps\/dir\//) expect(result).toContain('48.85,2.35') expect(result).toContain('48.86,2.36') }) }) // ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ──────── describe('withHotelBookends', () => { const hotel = { lat: 1, lng: 1 } const a = { lat: 2, lng: 2 } const b = { lat: 3, lng: 3 } const evening = { lat: 4, lng: 4 } it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => { const runs = [[a, b]] expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]]) }) it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => { const runs = [[a, b]] expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([ [hotel, a], [a, b], [b, evening], ]) }) it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => { expect(withHotelBookends([], a, a, hotel, evening)).toEqual([ [hotel, a], [a, evening], ]) }) it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => { const runs = [[a, b]] expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]]) }) it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => { const runs = [[a, b]] expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([ [hotel, a], [a, b], ]) }) }) // ── parsePluginProfile ───────────────────────────────────────────────────────── describe('parsePluginProfile', () => { it('FE-COMP-ROUTECALCULATOR-026: splits plugin:/ into its two halves', () => { expect(parsePluginProfile('plugin:ev-router/fastest')).toEqual({ pluginId: 'ev-router', profileId: 'fastest' }) }) it('FE-COMP-ROUTECALCULATOR-027: a profile id may itself contain slashes', () => { expect(parsePluginProfile('plugin:ev-router/eco/winter')).toEqual({ pluginId: 'ev-router', profileId: 'eco/winter' }) }) it('FE-COMP-ROUTECALCULATOR-028: a built-in profile is not a plugin profile', () => { expect(parsePluginProfile('driving')).toBeNull() expect(parsePluginProfile('walking')).toBeNull() }) it('FE-COMP-ROUTECALCULATOR-029: rejects a malformed plugin key', () => { expect(parsePluginProfile('plugin:ev-router')).toBeNull() // no separator expect(parsePluginProfile('plugin:/fastest')).toBeNull() // empty plugin id expect(parsePluginProfile('plugin:ev-router/')).toBeNull() // empty profile id }) }) // ── calculateRoute: remaining profiles ───────────────────────────────────────── describe('calculateRoute profiles', () => { it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => { server.use( http.get(`${FOSSGIS.cycling}/:coords`, () => HttpResponse.json(buildOsrmRouteResponse(9000, 4242)) ) ) const result = await calculateRoute([wp1, wp2], 'cycling') expect(result.duration).toBeCloseTo(9000 / (15000 / 3600)) // The raw OSRM duration is still reported as the driving estimate. expect(result.drivingText).toBe('1 h 10 min') }) }) // ── calculateSegments error paths ────────────────────────────────────────────── describe('calculateSegments failures', () => { it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 }))) await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated') }) it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] }))) await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found') }) }) // ── calculateRouteWithLegs ───────────────────────────────────────────────────── // The module caches by the exact waypoint list, so every test that must reach the // network needs coordinates no earlier test has used. let coordSeed = 0 function freshWaypoints(count = 2) { coordSeed += 1 return Array.from({ length: count }, (_, i) => ({ lat: 10 + coordSeed + i / 100, lng: 20 + coordSeed + i / 100 })) } const buildLegsResponse = (legCount = 1) => ({ code: 'Ok', routes: [{ geometry: { coordinates: [[2.35, 48.85], [2.36, 48.86], [2.37, 48.87]] }, distance: 4200, duration: 600, legs: Array.from({ length: legCount }, () => ({ distance: 4200 / legCount, duration: 600 / legCount })), }], }) function pluginRouteResult(over: Partial = {}): PluginRouteResult { return { pluginId: 'ev-router', profile: 'fastest', coordinates: [[48.85, 2.35], [48.9, 2.4]], distance: 120000, duration: 5400, legs: [{ distance: 120000, duration: 5400 }], viaPoints: [], ...over, } } afterEach(() => { vi.restoreAllMocks() useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, distance_unit: 'metric' } }) }) describe('calculateRouteWithLegs', () => { it('FE-COMP-ROUTECALCULATOR-033: returns an empty route for fewer than 2 waypoints without calling OSRM', async () => { const result = await calculateRouteWithLegs([wp1]) expect(result).toEqual({ coordinates: [], distance: 0, duration: 0, legs: [] }) }) it('FE-COMP-ROUTECALCULATOR-034: returns road geometry as [lat,lng] plus per-leg metadata', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse()))) const [a, b] = freshWaypoints() const result = await calculateRouteWithLegs([a, b]) // OSRM ships [lng,lat]; Leaflet wants [lat,lng]. expect(result.coordinates).toEqual([[48.85, 2.35], [48.86, 2.36], [48.87, 2.37]]) expect(result.distance).toBe(4200) expect(result.legs).toHaveLength(1) expect(result.legs[0].from).toEqual([a.lat, a.lng]) expect(result.legs[0].to).toEqual([b.lat, b.lng]) expect(result.legs[0].mid).toEqual([(a.lat + b.lat) / 2, (a.lng + b.lng) / 2]) expect(result.legs[0].distanceText).toBe('4.2 km') expect(result.legs[0].drivingText).toBe('10 min') expect(result.legs[0].walkingText).toBe('50 min') }) it('FE-COMP-ROUTECALCULATOR-035: a repeated call is served from the cache instead of the network', async () => { let hits = 0 server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) })) const wps = freshWaypoints() const first = await calculateRouteWithLegs(wps) const second = await calculateRouteWithLegs(wps) expect(hits).toBe(1) expect(second).toBe(first) }) it('FE-COMP-ROUTECALCULATOR-036: switching the distance unit re-fetches instead of reusing stale text (#1300)', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse()))) const wps = freshWaypoints() const metric = await calculateRouteWithLegs(wps) expect(metric.legs[0].distanceText).toBe('4.2 km') useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } }) const imperial = await calculateRouteWithLegs(wps) expect(imperial).not.toBe(metric) expect(imperial.legs[0].distanceText).toContain('mi') }) it('FE-COMP-ROUTECALCULATOR-037: walking and cycling go to their own FOSSGIS profile hosts', async () => { server.use( http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json(buildLegsResponse())), http.get(`${FOSSGIS.cycling}/:coords`, () => HttpResponse.json(buildLegsResponse())), ) await expect(calculateRouteWithLegs(freshWaypoints(), { profile: 'walking' })).resolves.toMatchObject({ distance: 4200 }) await expect(calculateRouteWithLegs(freshWaypoints(), { profile: 'cycling' })).resolves.toMatchObject({ distance: 4200 }) }) it('FE-COMP-ROUTECALCULATOR-038: an unknown profile falls back to the car host', async () => { let hits = 0 server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) })) await calculateRouteWithLegs(freshWaypoints(), { profile: 'hovercraft' }) expect(hits).toBe(1) }) it('FE-COMP-ROUTECALCULATOR-039: builds one leg per waypoint pair', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse(2)))) const wps = freshWaypoints(3) const result = await calculateRouteWithLegs(wps) expect(result.legs).toHaveLength(2) expect(result.legs[1].from).toEqual([wps[1].lat, wps[1].lng]) expect(result.legs[1].to).toEqual([wps[2].lat, wps[2].lng]) }) it('FE-COMP-ROUTECALCULATOR-040: throws on an OSRM HTTP error so the caller can fall back to a straight line', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 503 }))) await expect(calculateRouteWithLegs(freshWaypoints())).rejects.toThrow('Route could not be calculated') }) it('FE-COMP-ROUTECALCULATOR-041: throws when OSRM reports no route', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] }))) await expect(calculateRouteWithLegs(freshWaypoints())).rejects.toThrow('No route found') }) it('FE-COMP-ROUTECALCULATOR-042: a route without legs still returns its geometry', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[2.35, 48.85]] }, distance: 10, duration: 5 }], }))) const result = await calculateRouteWithLegs(freshWaypoints()) expect(result.legs).toEqual([]) expect(result.coordinates).toEqual([[48.85, 2.35]]) }) }) describe('calculateRouteWithLegs plugin profiles', () => { it('FE-COMP-ROUTECALCULATOR-043: refuses a plugin route without a trip context', async () => { const spy = vi.spyOn(pluginsApi, 'pluginRoute') await expect( calculateRouteWithLegs(freshWaypoints(), { profile: 'plugin:ev-router/fastest' }) ).rejects.toThrow('Plugin routing needs a trip context') expect(spy).not.toHaveBeenCalled() }) it('FE-COMP-ROUTECALCULATOR-044: forwards the trip/day context and the bare coordinates to the plugin', async () => { const spy = vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: pluginRouteResult() }) const wps = freshWaypoints() await calculateRouteWithLegs(wps, { profile: 'plugin:ev-router/fastest', tripId: 7, dayId: 3 }) expect(spy).toHaveBeenCalledWith( 'ev-router', 'fastest', { tripId: 7, dayId: 3, waypoints: wps.map(p => ({ lat: p.lat, lng: p.lng })) }, { signal: undefined }, ) }) it('FE-COMP-ROUTECALCULATOR-045: maps the plugin answer onto the normal route shape', async () => { vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: pluginRouteResult({ legs: [{ distance: 120000, duration: 5400, note: '25 min charge' }], viaPoints: [{ lat: 48.7, lng: 2.3, label: 'Supercharger', tone: 'success', dwellSeconds: 1500 }], }), }) const wps = freshWaypoints() const result = await calculateRouteWithLegs(wps, { profile: 'plugin:ev-router/fastest', tripId: 7 }) expect(result.coordinates).toEqual([[48.85, 2.35], [48.9, 2.4]]) expect(result.legs[0].noteText).toBe('25 min charge') expect(result.legs[0].drivingText).toBe('1 h 30 min') expect(result.legs[0].distanceText).toBe('120 km') expect(result.legs[0].from).toEqual([wps[0].lat, wps[0].lng]) expect(result.vias).toHaveLength(1) expect(result.vias?.[0].label).toBe('Supercharger') }) it('FE-COMP-ROUTECALCULATOR-046: a leg without a note carries no noteText, and no vias means no vias key', async () => { vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: pluginRouteResult() }) const result = await calculateRouteWithLegs(freshWaypoints(), { profile: 'plugin:ev-router/fastest', tripId: 7 }) expect(result.legs[0].noteText).toBeUndefined() expect('vias' in result).toBe(false) }) it('FE-COMP-ROUTECALCULATOR-047: a refusing plugin throws like an OSRM outage', async () => { vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: null }) await expect( calculateRouteWithLegs(freshWaypoints(), { profile: 'plugin:ev-router/fastest', tripId: 7 }) ).rejects.toThrow('No route found') }) it('FE-COMP-ROUTECALCULATOR-048: the same coordinates on a different day are routed again, not served from cache', async () => { const spy = vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: pluginRouteResult() }) const wps = freshWaypoints() const opts = { profile: 'plugin:ev-router/fastest', tripId: 7 } await calculateRouteWithLegs(wps, { ...opts, dayId: 1 }) await calculateRouteWithLegs(wps, { ...opts, dayId: 1 }) expect(spy).toHaveBeenCalledTimes(1) // A plugin may hand back different charging stops for another day, so the // cache key is scoped to trip + day. await calculateRouteWithLegs(wps, { ...opts, dayId: 2 }) expect(spy).toHaveBeenCalledTimes(2) }) }) // Runs last on purpose: it fills the module-level route cache to its cap, which // would evict the entries the tests above rely on. describe('calculateRouteWithLegs cache eviction', () => { it('FE-COMP-ROUTECALCULATOR-049: the route cache is capped and drops its oldest entry', async () => { const spy = vi.spyOn(pluginsApi, 'pluginRoute').mockResolvedValue({ route: pluginRouteResult() }) const opts = { profile: 'plugin:ev-router/fastest', tripId: 99 } const oldest = freshWaypoints() await calculateRouteWithLegs(oldest, opts) // ROUTE_CACHE_MAX is 200 — push past it so the first entry falls out again. for (let i = 0; i < 201; i++) await calculateRouteWithLegs(freshWaypoints(), opts) spy.mockClear() await calculateRouteWithLegs(oldest, opts) expect(spy).toHaveBeenCalledTimes(1) // The OSRM path writes into (and trims) the very same cache. let hits = 0 server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) })) await calculateRouteWithLegs(freshWaypoints()) expect(hits).toBe(1) }) }) // ── generateCoMapsUrl ───────────────────────────────────────────────────────── describe('generateCoMapsUrl', () => { const eiffel = { lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' } const louvre = { lat: 48.8606, lng: 2.3376, name: 'Louvre' } const notre = { lat: 48.8530, lng: 2.3499, name: 'Notre-Dame' } it('FE-COMP-ROUTECALCULATOR-016: no stops, no link', () => { expect(generateCoMapsUrl([])).toBeNull() }) it('FE-COMP-ROUTECALCULATOR-017: two stops build a real route, mode included', () => { expect(generateCoMapsUrl([eiffel, louvre], 'walking')).toBe( 'https://comaps.at/route?sll=48.8584,2.2945&saddr=Eiffel%20Tower' + '&dll=48.8606,2.3376&daddr=Louvre&type=pedestrian', ) }) it('FE-COMP-ROUTECALCULATOR-018: TREK profiles map onto CoMaps travel modes', () => { expect(generateCoMapsUrl([eiffel, louvre], 'driving')).toContain('type=vehicle') expect(generateCoMapsUrl([eiffel, louvre], 'cycling')).toContain('type=bicycle') // A plugin router has no CoMaps equivalent, so it falls back rather than // sending a mode CoMaps would reject. expect(generateCoMapsUrl([eiffel, louvre], 'plugin:ev/fast')).toContain('type=vehicle') }) it('FE-COMP-ROUTECALCULATOR-019: three stops go as pins, because a route link would drop the middle', () => { const url = generateCoMapsUrl([eiffel, louvre, notre])! expect(url).toBe( 'https://comaps.at/map?v=1&ll=48.8584,2.2945&n=Eiffel%20Tower' + '&ll=48.8606,2.3376&n=Louvre&ll=48.853,2.3499&n=Notre-Dame', ) }) it('FE-COMP-ROUTECALCULATOR-020: a single stop is a pin, and a nameless one is labelled by position', () => { expect(generateCoMapsUrl([{ lat: 48.85, lng: 2.35 }])) .toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35') }) }) describe('a self-hosted routing engine', () => { afterEach(() => { useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) }) const useOwnRouter = (base: string) => useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } })) it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => { useOwnRouter('https://osrm.example.org') let asked = '' server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => { asked = request.url return HttpResponse.json(buildOsrmRouteResponse()) })) await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving') expect(asked).toContain('osrm.example.org') }) it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => { useOwnRouter('https://osrm.example.org') let asked = '' // TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike". server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => { asked = request.url return HttpResponse.json(buildOsrmRouteResponse()) })) await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking') expect(asked).toContain('/route/v1/foot/') }) it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => { useOwnRouter('https://osrm.example.org///') let asked = '' server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => { asked = request.url return HttpResponse.json(buildOsrmRouteResponse()) })) await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving') expect(asked).not.toContain('//route') }) it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => { useOwnRouter(' ') let asked = '' server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { asked = request.url return HttpResponse.json(buildOsrmRouteResponse()) })) await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving') expect(asked).toContain('routing.openstreetmap.de') }) }) describe('calculateAlternatives', () => { const twoRoutes = { code: 'Ok', routes: [ // The direct one, straight down the middle. { geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 }, // A detour that swings well to the east before rejoining. { geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 }, ], } it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => { let asked = '' server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { asked = request.url return HttpResponse.json(twoRoutes) })) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(asked).toContain('alternatives=3') expect(routes).toHaveLength(2) expect(routes[0].distance).toBe(100000) }) it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes))) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) // The first is what the router gives anyway, so it needs no pinning point. expect(routes[0].divergence).toBeNull() // The second diverges at its eastern swing — saving that as a via forces this road. expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 }) }) /** The router's own answer, and whatever it says when a road class is left out. */ const withExclusions = (byExclude: Record) => http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) const answer = byExclude[exclude] return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] }) }) it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => { // Leaving the motorway out changed nothing — the leg does not touch one. Offering // the identical line a second time would be a choice that is not a choice. server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } })) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(routes).toHaveLength(1) expect(routes[0].divergence).toBeNull() }) it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => { // OSRM answers most long legs with exactly one route, which used to mean the picker // said there was no other way — while a perfectly good slower road existed. server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } })) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(routes).toHaveLength(2) expect(routes[1].avoids).toBe('motorway') // Pinnable like any other offer: the point where it leaves the direct line. expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 }) }) it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => { const asked: string[] = [] server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (exclude) asked.push(exclude) if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] }) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] }) })) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(asked).toEqual(['motorway', 'toll']) expect(routes[1].avoids).toBe('toll') }) it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => { // The foot profile has no excludable classes; asking earns an InvalidOptions for // nothing, and a walk has no motorway to avoid in the first place. const asked: string[] = [] server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (exclude) asked.push(exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) })) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking') expect(asked).toEqual([]) expect(routes).toHaveLength(1) }) it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => { // Both public hosts TREK ships with answer 400 here, so without this every long leg // pays two extra requests against a one-per-second limit to be refused twice. const asked: string[] = [] server.use(http.get('*/route/v1/driving/*', ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) asked.push(exclude) return new HttpResponse(null, { status: 400 }) })) await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving') const afterFirst = asked.length await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving') expect(afterFirst).toBe(1) expect(asked).toHaveLength(1) }) // The second engine, which is what made this whole branch produce anything on a // default install. Every case here overrides the 503 the shared handlers answer with, // so the OSRM path above stays the one the cases before this measure. const VALHALLA = 'https://valhalla1.openstreetmap.de/route' const valhallaAnswer = (summary: Record) => ({ trip: { // The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends. legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }], summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary }, }, }) it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })), http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false }))) ) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(routes).toHaveLength(2) expect(routes[1].avoids).toBe('motorway') expect(routes[1].distance).toBeCloseTo(130000, 0) expect(routes[1].duration).toBe(4500) }) it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => { // use_highways: 0 is a weighting, so a leg with no way round the motorway comes back // on the motorway and says so. Offering that as "No motorway" would put a lie on the // map, so it is dropped and the leg keeps the single way OSRM found. // // Asserted on the result rather than on which excludes OSRM was asked: by this point // `excludeUnsupported` has already recorded the public host from the case above, and // that Set is module state for the lifetime of the file. server.use( http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) return new HttpResponse(null, { status: 400 }) }), http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true }))) ) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 }) expect(routes).toHaveLength(1) expect(routes[0].avoids).toBeUndefined() }) it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) return new HttpResponse(null, { status: 400 }) }), http.post(VALHALLA, async ({ request }) => { const body = await request.json() as { costing_options: { auto: Record } } // Only the ferry question gets a usable answer, so the first two fall through. if (!('use_ferry' in body.costing_options.auto)) { return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true })) } return HttpResponse.json(valhallaAnswer({ has_ferry: false })) }) ) const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 }) expect(routes).toHaveLength(2) expect(routes[1].avoids).toBe('ferry') }) it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => { // The reason the geometry decides rather than the numbers. Valhalla prices roads // differently, so on a long leg the SAME road comes back kilometres apart in both // length and time — here 13 km and 5 minutes, which is the measured spread between // the two engines and comfortably past the 200 m the numbers used to allow. Judged // on those it reads as a discovery and puts a second blue line on top of the first. server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })), http.post(VALHALLA, () => HttpResponse.json({ trip: { // twoRoutes.routes[0]'s own line, re-encoded: the identical road. legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }], summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false }, }, })) ) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }) expect(routes).toHaveLength(1) }) it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => { let valhallaCalls = 0 useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } })) server.use( http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => { const exclude = new URL(request.url).searchParams.get('exclude') if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] }) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] }) }), http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) }) ) const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 }) expect(valhallaCalls).toBe(0) expect(routes[1].avoids).toBe('motorway') useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) }) it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => { // OSRM reports these in every answer and TREK threw them away since the first route // was drawn. Without them a place set back from the road looks like it is on the // route, while the drive really starts somewhere else. server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({ code: 'Ok', waypoints: [ { location: [10.02, 53.51], distance: 812.5 }, { location: [13.0, 52.0], distance: 4 }, ], routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }], }))) const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }]) expect(r.snapped).toEqual([ { asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 }, { asked: [52, 13], at: [52.0, 13.0], meters: 4 }, ]) }) it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => { server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({ code: 'Ok', waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }], routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }], }))) const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }]) // Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a // real number rather than the whole answer being discarded. expect(r.snapped?.[0].meters).toBeGreaterThan(1000) expect(r.snapped?.[0].meters).toBeLessThan(1200) }) it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => { // All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one // bad entry would hang the spur off the wrong stop. server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({ code: 'Ok', waypoints: [{ location: [10.0, 53.0] }], routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }], }))) const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }]) expect(r.snapped).toBeUndefined() expect(r.coordinates).toHaveLength(2) }) it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] }))) await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([]) }) it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => { server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 }))) await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })) .rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 }) }) }) // ── turning round at a stop ──────────────────────────────────────────────────── /** * Gouffre de Padirac snaps onto `Route du Puits au Salvage`, which leads nowhere else. * OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked, and it * refuses the ENTIRE request when it cannot obey, so a road-trip day lost every leg it * had over one cave car park, while each of its pairs routed perfectly on its own. */ describe('turning round at a stop', () => { const askedFor = async ( call: (points: { lat: number; lng: number }[]) => Promise, count: number, ): Promise => { let asked = '' server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => { asked = request.url return HttpResponse.json(buildOsrmRouteResponse()) })) await call(freshWaypoints(count)) return asked } it('FE-COMP-ROUTECALCULATOR-055: a drive that stops on the way may turn round where it stopped', async () => { expect(await askedFor(p => calculateRoute(p), 3)).toContain('continue_straight=false') expect(await askedFor(p => calculateRouteWithLegs(p), 3)).toContain('continue_straight=false') expect(await askedFor(p => calculateSegments(p), 3)).toContain('continue_straight=false') }) it('FE-COMP-ROUTECALCULATOR-056: two points have no middle, so the request is the one it always was', async () => { expect(await askedFor(p => calculateRoute(p), 2)).not.toContain('continue_straight') expect(await askedFor(p => calculateRouteWithLegs(p), 2)).not.toContain('continue_straight') expect(await askedFor(p => calculateSegments(p), 2)).not.toContain('continue_straight') }) it('FE-COMP-ROUTECALCULATOR-057: a router that has never heard of the permission is asked without it, once and then always', async () => { // OSRM refuses an unknown parameter outright instead of ignoring it, and says which // it is refusing: `InvalidQuery` names the query string, `NoRoute` names the road. // Without this a router that predates the parameter would refuse every drive with a // stop in the middle, which is a far worse fault than the one it fixes. useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://old-osrm.example.org' } })) const asked: string[] = [] server.use(http.get('https://old-osrm.example.org/route/v1/driving/:coords', ({ request }) => { asked.push(request.url) return new URL(request.url).searchParams.has('continue_straight') ? HttpResponse.json({ code: 'InvalidQuery', message: 'Query string malformed' }, { status: 400 }) : HttpResponse.json(buildOsrmRouteResponse()) })) const first = await calculateRoute(freshWaypoints(3)) expect(first.coordinates).toHaveLength(2) expect(asked).toHaveLength(2) expect(asked[1]).not.toContain('continue_straight') // Remembered per host, so the second drive costs one request rather than two. await calculateRoute(freshWaypoints(3)) expect(asked).toHaveLength(3) expect(asked[2]).not.toContain('continue_straight') useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) }) it('FE-COMP-ROUTECALCULATOR-058: a refusal about the road is still a refusal, not a second request', async () => { useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://noroute-osrm.example.org' } })) let calls = 0 server.use(http.get('https://noroute-osrm.example.org/route/v1/driving/:coords', () => { calls += 1 return HttpResponse.json({ code: 'NoRoute', message: 'No route found between points' }, { status: 400 }) })) await expect(calculateRoute(freshWaypoints(3))).rejects.toThrow() expect(calls).toBe(1) useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } })) }) }) // ── a trip that avoids something ────────────────────────────────────────────── /** * The rail drives a trip with avoided classes through Valhalla, and everything that talks * about one of its legs has to use the same engine. The offers used to come from OSRM with * nothing avoided, so the picker described a different drive from the rail's; and an OSRM * stand-in for a Valhalla that did not answer was filed as the avoided road for the rest * of the session, without a word on the rail. */ describe('a trip that avoids something', () => { const VALHALLA = 'https://valhalla1.openstreetmap.de/route' /** Polyline6, the way Valhalla sends its shapes. */ const encode6 = (points: [number, number][]): string => { const part = (value: number): string => { let n = value < 0 ? ~(value << 1) : value << 1 let out = '' while (n >= 0x20) { out += String.fromCharCode((0x20 | (n & 0x1f)) + 63); n >>= 5 } return out + String.fromCharCode(n + 63) } let lat = 0 let lng = 0 return points.map(([pLat, pLng]) => { const a = Math.round(pLat * 1e6) const b = Math.round(pLng * 1e6) const piece = part(a - lat) + part(b - lng) lat = a lng = b return piece }).join('') } const trip = (points: [number, number][], summary: Record = {}) => ({ legs: [{ shape: encode6(points) }], summary: { length: 100, time: 4000, has_toll: false, has_highway: false, has_ferry: false, ...summary }, }) const NORTH: [number, number][] = [[61, 10], [61.5, 11], [61, 12]] const SOUTH: [number, number][] = [[61, 10], [60.5, 11], [61, 12]] const DIRECT: [number, number][] = [[61, 10], [61, 11], [61, 12]] const from = { lat: 61, lng: 10 } const to = { lat: 61, lng: 12 } it('FE-COMP-ROUTECALCULATOR-059: the ways come from Valhalla with the trip classes, and OSRM is never asked', async () => { let osrm = 0 const bodies: Array> = [] server.use( http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }), http.post(VALHALLA, async ({ request }) => { bodies.push(await request.json() as Record) return HttpResponse.json({ trip: trip(DIRECT, { time: 3600 }), alternates: [{ trip: trip(NORTH, { time: 4200, has_ferry: true }) }, { trip: trip(SOUTH, { time: 4800 }) }], }) }), ) const routes = await calculateAlternatives(from, to, 'driving', { avoid: ['toll'] }) expect(osrm).toBe(0) expect(bodies).toHaveLength(1) expect(bodies[0]).toMatchObject({ alternates: 2, costing_options: { auto: { use_tolls: 0 } } }) expect(bodies[0].locations).toHaveLength(2) expect(routes.map(r => r.engine)).toEqual(['valhalla', 'valhalla', 'valhalla']) expect(routes.map(r => r.duration)).toEqual([3600, 4200, 4800]) expect(routes.map(r => r.hasFerry)).toEqual([false, true, false]) // Pinnable like any other offer: measured against the preferred one. expect(routes[0].divergence).toBeNull() expect(routes[1].divergence).toEqual({ lat: 61.5, lng: 11 }) }) it('FE-COMP-ROUTECALCULATOR-060: a Valhalla with nothing to say is the router not answering, not the OSRM roads', async () => { let osrm = 0 server.use( http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildOsrmRouteResponse()) }), http.post(VALHALLA, () => new HttpResponse(null, { status: 503 })), ) await expect(calculateAlternatives({ lat: 62, lng: 10 }, { lat: 62, lng: 12 }, 'driving', { avoid: ['ferry'] })).rejects.toThrow() expect(osrm).toBe(0) }) it('FE-COMP-ROUTECALCULATOR-061: one way back is offered a second only for a class the trip does not avoid yet', async () => { const asked: Array> = [] server.use(http.post(VALHALLA, async ({ request }) => { const body = await request.json() as { alternates?: number; costing_options: { auto: Record } } asked.push(body.costing_options.auto) if (body.alternates) return HttpResponse.json({ trip: trip(DIRECT, { has_toll: true }) }) // Asked to leave out the tolls as well: a road that really is without them. return HttpResponse.json({ trip: trip(NORTH, { time: 5000, has_toll: false }) }) })) const routes = await calculateAlternatives({ lat: 61, lng: 10.5 }, { lat: 61, lng: 12 }, 'driving', { avoid: ['motorway'] }) // Motorways are avoided already, so the first extra question is about tolls, and it // keeps the trip's own class in. expect(asked).toEqual([{ use_highways: 0 }, { use_highways: 0, use_tolls: 0 }]) expect(routes).toHaveLength(2) expect(routes[1]).toMatchObject({ avoids: 'toll', engine: 'valhalla', duration: 5000 }) }) it('FE-COMP-ROUTECALCULATOR-062: only a driving leg is weighed; a walk is asked of OSRM as before', async () => { let valhalla = 0 server.use( http.get(`${FOSSGIS.walking}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 61], [12, 61]] }, distance: 1000, duration: 900 }] })), http.post(VALHALLA, () => { valhalla++; return HttpResponse.json({ trip: trip(DIRECT) }) }), ) const routes = await calculateAlternatives(from, to, 'walking', { avoid: ['toll'] }) expect(valhalla).toBe(0) expect(routes).toHaveLength(1) expect(routes[0].engine).toBeUndefined() expect(avoidedClasses('walking', ['toll'])).toEqual([]) expect(avoidedClasses('driving', ['toll', 'ferry'])).toEqual(['ferry', 'toll']) expect(routeEngineFor('driving', ['toll'])).toBe('valhalla') expect(routeEngineFor('driving', [])).toBe('osrm') expect(routeEngineFor('plugin:ev/fast', ['toll'])).toBe('plugin') }) it('FE-COMP-ROUTECALCULATOR-063: an OSRM stand-in says nothing was avoided, and is not filed as the avoided road', async () => { const [a, b] = freshWaypoints() let osrm = 0 let valhallaUp = false server.use( http.get(`${FOSSGIS.driving}/:coords`, () => { osrm++; return HttpResponse.json(buildLegsResponse()) }), http.post(VALHALLA, () => (valhallaUp ? HttpResponse.json({ trip: { legs: [{ shape: encode6([[a.lat, a.lng], [b.lat, b.lng]]), summary: { length: 5, time: 700, has_toll: false, has_ferry: true } }] } }) : new HttpResponse(null, { status: 503 }))), ) const fallback = await calculateRouteWithLegs([a, b], { avoid: ['toll'] }) expect(osrm).toBe(1) expect(fallback.avoidance).toEqual({ asked: ['toll'], achieved: [], fellBack: true }) expect(fallback.distance).toBe(4200) // Valhalla is back: the same drive asks it again instead of keeping the OSRM answer. valhallaUp = true const weighed = await calculateRouteWithLegs([a, b], { avoid: ['toll'] }) expect(weighed.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] }) expect(weighed.distance).toBe(5000) expect(weighed.hasFerry).toBe(true) // The stand-in was filed where it is true: as the plain OSRM answer, which an ordinary // caller now gets without asking again, and without the avoidance attached. const plain = await calculateRouteWithLegs([a, b]) expect(osrm).toBe(1) expect(plain.distance).toBe(4200) expect(plain.avoidance).toBeUndefined() }) it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => { // The shape of a leg bent by a via: every point of the plain road lies on it, so // asked only one way round the two read as one. const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 } const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], distance: 166_000, duration: 5400 } expect(sameRoad(plainRoad, spur)).toBe(false) expect(sameRoad(spur, plainRoad)).toBe(false) expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true) }) it('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => { // Two engines' lines of one long road share almost no vertex. Measured to the nearest // sampled vertex, a point on the same road sat half a sample off it: kilometres here. // The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits // exactly between those samples, 1.8 km from the nearest, on the very same road. const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number]) const sparse: [number, number][] = [ [50, 0], ...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]), [50, 10], ] const a = { coordinates: dense, distance: 700_000, duration: 25_000 } const b = { coordinates: sparse, distance: 712_000, duration: 27_000 } expect(sameRoad(a, b)).toBe(true) const off = furthestFrom([[50.02, 5]], dense) expect(off?.index).toBe(0) expect(off?.km).toBeCloseTo(2.22, 1) }) it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => { server.use( http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })), http.post(VALHALLA, () => HttpResponse.json({ trip: trip([[63, 10], [63.6, 11], [63, 12]], { has_highway: false, has_ferry: true }) })), ) const routes = await calculateAlternatives({ lat: 63, lng: 10 }, { lat: 63, lng: 12 }) expect(routes).toHaveLength(2) expect(routes[1]).toMatchObject({ avoids: 'motorway', engine: 'valhalla', hasFerry: true }) }) })