Files
TREK/client/tests/integration/hooks/useTransportRoutes.test.ts
Maurice 3902e77991 feat(roadtrip): add the road trip addon with a drive-first plan view
The trip planner could show one day's route at a time and nothing across days,
so a road trip had to be pieced together in Google Maps (#1797, #435).

The addon adds a mode to the plan view rather than a tab of its own: the map
stays the workspace and gains the route over the whole trip, while the left rail
becomes one chain of stops with the driving distance and time between them and
arrival times that follow from them. A time pinned on a stop is kept and restarts
the chain, so a ferry or a museum ticket stays where the user put it.

Car bookings can finally carry the stops the schema has always allowed: the
endpoints table has held role/sequence for every transport type since #384, but
only flights and trains had an editor for them. The MCP tools already accepted
them, so this closes the gap the other way round.

Legs are fetched one at a time and spaced out — the public routing hosts answer a
whole trip's worth of requests with 429 — and the distance cap now looks at the
single leg instead of the sum, which is what dropped long drives entirely.
2026-09-07 18:36:54 +02:00

142 lines
7.2 KiB
TypeScript

import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Reservation } from '../../../src/types'
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
import { useTransportRoutes } from '../../../src/hooks/useTransportRoutes'
function booking(id: number, type: string, from: [number, number], to: [number, number]): Reservation {
return {
id,
type,
status: 'confirmed',
endpoints: [
{ role: 'from', sequence: 0, name: 'A', code: null, lat: from[0], lng: from[1], timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 1, name: 'B', code: null, lat: to[0], lng: to[1], timezone: null, local_time: null, local_date: null },
],
} as unknown as Reservation
}
const PARIS: [number, number] = [48.8566, 2.3522]
const VERSAILLES: [number, number] = [48.8049, 2.1204]
beforeEach(() => {
calculateRouteWithLegs.mockReset()
calculateRouteWithLegs.mockResolvedValue({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
})
describe('useTransportRoutes (#1425 real road routes)', () => {
it('routes a car booking with the driving profile and returns its geometry', async () => {
const res = [booking(1, 'car', PARIS, VERSAILLES)]
const { result } = renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(result.current.get(1)).toBeTruthy())
expect(result.current.get(1)).toHaveLength(3)
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'driving' }))
})
it('routes a bicycle booking with the cycling profile', async () => {
const res = [booking(2, 'bicycle', PARIS, VERSAILLES)]
renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
expect(calculateRouteWithLegs).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ profile: 'cycling' }))
})
it('does not route non-road types (flight, train, transit)', async () => {
const res = [
booking(3, 'flight', PARIS, VERSAILLES),
booking(4, 'train', PARIS, VERSAILLES),
booking(5, 'transit', PARIS, VERSAILLES),
]
renderHook(() => useTransportRoutes(res))
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('skips routing beyond the sanity distance cap (keeps the straight line)', async () => {
// Paris → Tokyo as a "car" booking: ~9700 km, well past the 2000 km cap.
const res = [booking(6, 'car', PARIS, [35.68, 139.69])]
renderHook(() => useTransportRoutes(res))
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('routes a road trip whose stops add up past the cap, because no single leg does (#1797)', async () => {
// Paris → Munich → Vienna → Budapest: ~1800 km end to end, every leg well under
// the cap. Before the cap moved to the leg, the sum alone dropped the whole drive.
const res = [{
id: 20,
type: 'car',
status: 'confirmed',
endpoints: [
{ role: 'from', sequence: 0, name: 'Paris', code: null, lat: 48.8566, lng: 2.3522, timezone: null, local_time: null, local_date: null },
{ role: 'stop', sequence: 1, name: 'Munich', code: null, lat: 48.1351, lng: 11.582, timezone: null, local_time: null, local_date: null },
{ role: 'stop', sequence: 2, name: 'Vienna', code: null, lat: 48.2082, lng: 16.3738, timezone: null, local_time: null, local_date: null },
{ role: 'to', sequence: 3, name: 'Budapest', code: null, lat: 47.4979, lng: 19.0402, timezone: null, local_time: null, local_date: null },
],
} as unknown as Reservation]
const { result } = renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(result.current.get(20)).toBeTruthy())
expect(calculateRouteWithLegs).toHaveBeenCalledWith(
[
{ lat: 48.8566, lng: 2.3522 },
{ lat: 48.1351, lng: 11.582 },
{ lat: 48.2082, lng: 16.3738 },
{ lat: 47.4979, lng: 19.0402 },
],
expect.objectContaining({ profile: 'driving' }),
)
})
it('falls back silently (no entry) when routing throws', async () => {
calculateRouteWithLegs.mockRejectedValueOnce(new Error('OSRM down'))
const res = [booking(7, 'taxi', PARIS, VERSAILLES)]
const { result } = renderHook(() => useTransportRoutes(res))
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalled())
await new Promise(r => setTimeout(r, 10))
expect(result.current.get(7)).toBeUndefined()
})
it('does not re-request an unchanged booking when the array identity changes', async () => {
const res1 = [booking(8, 'car', PARIS, VERSAILLES)]
const { rerender } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: res1 } })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
// New array, same booking coordinates → no second fetch.
rerender({ r: [booking(8, 'car', PARIS, VERSAILLES)] })
await new Promise(r => setTimeout(r, 20))
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
})
it('retries a booking whose in-flight request was cancelled before completing, instead of leaving it permanently unrouted', async () => {
// A realistic fetch-like mock: the first call only settles when its signal
// fires 'abort' — this lets the test control exactly when the in-flight
// request is cancelled, the way React StrictMode's dev-only
// mount->cleanup->remount cycle cancels the first attempt before it
// settles (both are "this render's request got cancelled" from the
// hook's point of view; only the trigger differs).
calculateRouteWithLegs.mockImplementationOnce((_points, opts) => new Promise((_resolve, reject) => {
opts.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
}))
calculateRouteWithLegs.mockResolvedValueOnce({ coordinates: [PARIS, [48.83, 2.24], VERSAILLES], distance: 20000, duration: 1500, legs: [] })
const carRes = booking(9, 'car', PARIS, VERSAILLES)
const unrelated = [booking(10, 'flight', PARIS, VERSAILLES)] // no routable type -> next effect run cancels the pending request and attempts nothing new
const { rerender, result } = renderHook(({ r }) => useTransportRoutes(r), { initialProps: { r: [carRes] } })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1))
// Deps change while the request is still in flight -> the effect's cleanup
// must cancel it (same hook instance, same attemptedRef, so this is a
// deterministic stand-in for StrictMode's fake unmount).
rerender({ r: unrelated })
await new Promise(r => setTimeout(r, 10))
// The same car booking comes back -> it must be retried, not silently
// skipped as "already attempted".
rerender({ r: [carRes] })
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
await waitFor(() => expect(result.current.get(9)).toBeTruthy())
expect(result.current.get(9)).toHaveLength(3)
})
})