mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
e6daed9403
The drive from where one day ended to where the next begins was the one stretch of a connected road trip that offered no other ways. The band above a card's first stop had no button on the desk, and the phone did not show that drive at all. A via behind a day's last stop, where the map already files a point dropped on that drive, was deleted when the day was reordered under Days, and was left on no stop when that last stop was removed or dragged up the day. Switching the avoided road classes or the day's profile kept the old road and minutes on it until a reload. The drive in now opens the same picker as any leg, on the desk band and on a new row at the head of the phone's chain that names where it leaves. A choice is proven with the seam's own router and written behind the last stop of the day before, also when a day with a single stop lies between. The rule for that via lives once in shared (carriedSeam): it stays with the last stop while that stop stays last, and is read like any other once it is not, for the planner's drags and removals as for the server's sorts and seated nights. The cached answer for a seam now records its mode and avoided classes, so changing either asks for it again.
1113 lines
57 KiB
TypeScript
1113 lines
57 KiB
TypeScript
import { renderHook, waitFor, act } from '@testing-library/react'
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
|
|
import type { RoadtripStop } from './useRoadtripRoutes'
|
|
|
|
// Hoisted together with the mock: the module factory runs before the file body, so a
|
|
// class declared down there would not exist yet when the hook does its `instanceof`.
|
|
const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
|
|
class RoutingRefusedError extends Error {
|
|
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
|
super('refused')
|
|
this.name = 'RoutingRefusedError'
|
|
}
|
|
get isRateLimit(): boolean { return this.status === 429 || this.status === 503 }
|
|
}
|
|
return { calculateRouteWithLegs: vi.fn(), RoutingRefusedError }
|
|
})
|
|
// The rest of the module stays real: which engine a leg belongs to is decided there, and
|
|
// the leg router has to agree with it rather than with a copy.
|
|
vi.mock('../Map/RouteCalculator', async importOriginal => ({
|
|
...(await importOriginal<typeof import('../Map/RouteCalculator')>()),
|
|
calculateRouteWithLegs,
|
|
RoutingRefusedError,
|
|
}))
|
|
|
|
import { useRoadtripRoutes } from './useRoadtripRoutes'
|
|
import { DEFAULT_SETTINGS, useSettingsStore } from '../../store/settingsStore'
|
|
import { lineMetres } from './corridor'
|
|
|
|
const HAMBURG: [number, number] = [53.5511, 9.9937]
|
|
const LUENEBURG: [number, number] = [53.2464, 10.4115]
|
|
const BERLIN: [number, number] = [52.52, 13.405]
|
|
|
|
interface StopSpec {
|
|
id: number
|
|
at: [number, number]
|
|
time?: string | null
|
|
dwell?: number | null
|
|
legMode?: string | null
|
|
incoming?: string | null
|
|
stopType?: string | null
|
|
noCoords?: boolean
|
|
/** The visit's own End. */
|
|
end?: string | null
|
|
/** An End only the place carries, for a visit that has none of its own. */
|
|
placeEnd?: string | null
|
|
}
|
|
|
|
function day(id: number, number: number, extra: Partial<Day> = {}): Day {
|
|
return { id, day_number: number, date: null, title: null, ...extra } as unknown as Day
|
|
}
|
|
|
|
function assignment(spec: StopSpec, order: number): Assignment {
|
|
return {
|
|
id: spec.id,
|
|
place_id: spec.id * 10,
|
|
order_index: order,
|
|
assignment_time: spec.time ?? null,
|
|
assignment_end_time: spec.end ?? null,
|
|
leg_transport_mode: spec.legMode ?? null,
|
|
incoming_leg_transport_mode: spec.incoming ?? null,
|
|
place: {
|
|
id: spec.id * 10,
|
|
name: `Stop ${spec.id}`,
|
|
lat: spec.noCoords ? null : spec.at[0],
|
|
lng: spec.noCoords ? null : spec.at[1],
|
|
place_time: null,
|
|
// Folded in the way the store keeps a visit (mergeAssignmentPlace): its own End,
|
|
// or else the place's.
|
|
end_time: spec.end ?? spec.placeEnd ?? null,
|
|
duration_minutes: spec.dwell ?? null,
|
|
stop_type: spec.stopType ?? null,
|
|
},
|
|
} as unknown as Assignment
|
|
}
|
|
|
|
const map = (dayId: number, stops: StopSpec[]): AssignmentsMap =>
|
|
({ [String(dayId)]: stops.map(assignment) }) as unknown as AssignmentsMap
|
|
|
|
/** A router answer with one leg per consecutive pair, the way OSRM replies. */
|
|
const routed = (legs: number, coordinates: [number, number][] = [HAMBURG, BERLIN]) => ({
|
|
coordinates,
|
|
distance: 100000,
|
|
duration: 3600,
|
|
legs: Array.from({ length: legs }, () => ({ distance: 100000 / legs, duration: 3600 / legs, text: '100 km' })),
|
|
})
|
|
|
|
beforeEach(() => {
|
|
calculateRouteWithLegs.mockReset()
|
|
calculateRouteWithLegs.mockResolvedValue(routed(1))
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('automatic daily travel times', () => {
|
|
beforeEach(() => {
|
|
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' } })
|
|
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 43200, legs: [{ ...routed(1).legs[0], duration: 43200 }] })
|
|
})
|
|
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
|
|
|
it('splits existing routes, keeps manual starts and recalculates without routing the pause', async () => {
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const original = [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN }]
|
|
const { result, rerender } = renderHook(({ stops }) => useRoadtripRoutes(7, days, map(1, stops)), {
|
|
initialProps: { stops: original as StopSpec[] },
|
|
})
|
|
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
|
expect(result.current.days).toHaveLength(2)
|
|
expect(result.current.totalStops).toBe(2)
|
|
expect(result.current.totalDuration).toBe(43200)
|
|
expect(result.current.days[0].schedule.entries.map(e => e.arrival)).toEqual(['07:00', '18:00'])
|
|
expect(result.current.days[1].schedule.entries.map(e => e.arrival)).toEqual(['08:00', '09:00'])
|
|
expect(result.current.days[1].stops[1]).toMatchObject({ assignmentId: 2, ownerDayId: 1, ownerIndex: 1 })
|
|
const end = result.current.days[0].stops[1]
|
|
rerender({ stops: [{ ...original[0], dwell: 120 }, original[1]] })
|
|
await waitFor(() => expect(result.current.days[1].schedule.entries[1].arrival).toBe('11:00'))
|
|
expect(result.current.days[0].stops[1].lng).not.toBe(end.lng)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } }))
|
|
await waitFor(() => expect(result.current.days).toHaveLength(1))
|
|
expect(result.current.days[0].schedule.entries[1].arrival).toBe('21:00')
|
|
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
|
})
|
|
|
|
it('routes the join between stored days and includes it exactly once', async () => {
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const assignments = { ...map(1, [{ id: 1, at: HAMBURG }]), ...map(2, [{ id: 2, at: BERLIN }]) }
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
|
await waitFor(() => expect(result.current.dayWindowIssue).toBeNull())
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
expect(calculateRouteWithLegs.mock.calls[0][0]).toEqual([{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: BERLIN[0], lng: BERLIN[1] }])
|
|
expect(result.current.days.map(d => d.dayNumber)).toEqual([1, 2, 3])
|
|
expect(result.current.totalDuration).toBe(43200)
|
|
expect(result.current.totalDistance).toBe(100000)
|
|
expect(result.current.totalStops).toBe(2)
|
|
})
|
|
|
|
it('exposes a fixed appointment conflict and keeps stored dates and times', async () => {
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const assignments = map(1, [{ id: 1, at: HAMBURG, time: '07:00' }, { id: 2, at: BERLIN, time: '10:00' }])
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
|
await waitFor(() => expect(result.current.dayWindowIssue).toBe('conflict'))
|
|
expect(result.current.days).toHaveLength(1)
|
|
expect(result.current.days[0].stops.map(s => s.time)).toEqual(['07:00', '10:00'])
|
|
expect(result.current.days[0].stops.some(s => s.automaticNight)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('useRoadtripRoutes', () => {
|
|
it('FE-ROADTRIP-ROUTES-001: routes a whole day in one request, not one per leg', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG },
|
|
{ id: 2, at: LUENEBURG },
|
|
{ id: 3, at: BERLIN },
|
|
]
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
const [waypoints, options] = calculateRouteWithLegs.mock.calls[0]
|
|
expect(waypoints).toHaveLength(3)
|
|
expect(options).toMatchObject({ profile: 'driving', tripId: 7, dayId: 1 })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-002: a day whose legs are driven and cycled becomes two requests', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG, legMode: 'driving' },
|
|
{ id: 2, at: LUENEBURG, legMode: 'cycling' },
|
|
{ id: 3, at: BERLIN },
|
|
]
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
const profiles = calculateRouteWithLegs.mock.calls.map(c => c[1].profile)
|
|
expect(profiles).toEqual(['driving', 'cycling'])
|
|
})
|
|
|
|
it("FE-ROADTRIP-ROUTES-003: the day's own default mode wins over the trip fallback", async () => {
|
|
const days = [day(1, 1, { default_transport_mode: 'walking' } as Partial<Day>)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }]
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving'))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs.mock.calls[0][1].profile).toBe('walking')
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-004: drops a stop without coordinates and a day left with one stop', async () => {
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG, noCoords: true }]),
|
|
...map(2, [{ id: 3, at: HAMBURG }, { id: 4, at: BERLIN }]),
|
|
} as AssignmentsMap
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// Day 1 lost its second stop and with it its reason to appear at all.
|
|
expect(result.current.days.map(d => d.dayId)).toEqual([2])
|
|
expect(result.current.totalStops).toBe(2)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-013: a charger on the way is part of the drive, not a stop', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG },
|
|
{ id: 2, at: [53, 11], stopType: 'charging' },
|
|
{ id: 3, at: BERLIN },
|
|
]
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, [53, 11], BERLIN]))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// Three assignments, three markers on the map, two places the trip is for. The
|
|
// charger still routes — it is only left out of the count the head shows.
|
|
expect(result.current.days[0].stops).toHaveLength(3)
|
|
expect(result.current.totalStops).toBe(2)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-005: sums the legs and hands the map one polyline per routed run', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
calculateRouteWithLegs.mockResolvedValue(routed(1, [HAMBURG, [53, 11], BERLIN]))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(result.current.totalDistance).toBe(100000)
|
|
expect(result.current.totalDuration).toBe(3600)
|
|
expect(result.current.lines).toEqual([[HAMBURG, [53, 11], BERLIN]])
|
|
// The corridor search reads this: the roads driven, not the line between stops.
|
|
expect(result.current.days[0].geometry).toEqual([HAMBURG, [53, 11], BERLIN])
|
|
expect(result.current.segments).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-006: walks the clock forward from the first pinned time', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 30 },
|
|
{ id: 2, at: BERLIN, dwell: 60 },
|
|
]
|
|
// One hour of driving between them.
|
|
calculateRouteWithLegs.mockResolvedValue(routed(1))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
const entries = result.current.days[0].schedule.entries
|
|
expect(entries[0]).toMatchObject({ arrival: '09:00', departure: '09:30', anchored: true })
|
|
expect(entries[1]).toMatchObject({ arrival: '10:30', departure: '11:30', anchored: false })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-007: retries a leg that did not route, then keeps it blank', async () => {
|
|
vi.useFakeTimers()
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
// Two backoffs, then it gives up rather than inventing a duration.
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(8000) })
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
|
expect(result.current.loading).toBe(false)
|
|
expect(result.current.days[0].legs).toEqual([undefined])
|
|
expect(result.current.totalDistance).toBe(0)
|
|
// A leg that never routed breaks the chain instead of guessing past it.
|
|
expect(result.current.days[0].schedule.entries[1].arrival).toBeNull()
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-008: a leg that answers on the second try still lands', async () => {
|
|
vi.useFakeTimers()
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
calculateRouteWithLegs
|
|
.mockRejectedValueOnce(new Error('429'))
|
|
.mockResolvedValue(routed(1))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(3000) })
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
expect(result.current.totalDistance).toBe(100000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-028: a day the router refuses outright is asked for again one pair at a time', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
|
// The Padirac case: one stop in the middle the router will not turn round at, and
|
|
// what comes back is a refusal of the WHOLE chain rather than of that one leg.
|
|
calculateRouteWithLegs
|
|
.mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
|
.mockResolvedValue(routed(1))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 5000 })
|
|
|
|
// The run once, refused, then a request per pair, and no retry of the run, because
|
|
// the same coordinates earn the same refusal.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
|
expect(calculateRouteWithLegs.mock.calls[1][0]).toHaveLength(2)
|
|
expect(calculateRouteWithLegs.mock.calls[2][0]).toHaveLength(2)
|
|
expect(result.current.days[0].legs.filter(Boolean)).toHaveLength(2)
|
|
expect(result.current.totalDistance).toBe(200000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-029: a rate limit is waited out, never split into more requests', async () => {
|
|
vi.useFakeTimers()
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
|
calculateRouteWithLegs.mockRejectedValue(new RoutingRefusedError(429, null))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(20000) })
|
|
|
|
// Three attempts at the run and nothing after them: answering a host that asked for
|
|
// less traffic with two more requests would be the opposite of backing off.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
|
expect(result.current.days[0].legs).toEqual([undefined, undefined])
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-009: renaming a place does not re-ask the router', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
const first = map(1, stops)
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
|
|
{ initialProps: { assignments: first } },
|
|
)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
|
|
const renamed = JSON.parse(JSON.stringify(first)) as AssignmentsMap
|
|
renamed['1'][0].place!.name = 'Somewhere else entirely'
|
|
rerender({ assignments: renamed })
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-010: a trip with nothing to drive asks for nothing', async () => {
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }])))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
|
expect(result.current.days).toEqual([])
|
|
expect(result.current.totalStops).toBe(0)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-012: a stop whose id changes after saving keeps its legs and its clock', async () => {
|
|
// The optimistic insert: a stop added mid-day carries a temporary negative id until
|
|
// the server answers, then the real id replaces it without a coordinate moving. Since
|
|
// `planKey` is geometry only, no refetch follows — so legs filed under the id would be
|
|
// stranded, and a broken chain blanks every arrival after it.
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
|
const days = [day(1, 1)]
|
|
const optimistic: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG, time: '09:00' },
|
|
{ id: -1755000000, at: LUENEBURG },
|
|
{ id: 2, at: BERLIN },
|
|
]
|
|
const first = map(1, optimistic)
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ assignments }: { assignments: AssignmentsMap }) => useRoadtripRoutes(7, days, assignments),
|
|
{ initialProps: { assignments: first } },
|
|
)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
|
|
|
const saved = JSON.parse(JSON.stringify(first)) as AssignmentsMap
|
|
saved['1'][1].id = 4711
|
|
rerender({ assignments: saved })
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
expect(result.current.days[0].legs.every(Boolean)).toBe(true)
|
|
expect(result.current.days[0].schedule.entries[2].arrival).not.toBeNull()
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-013: a rate-limited host is given the time it asked for', async () => {
|
|
// The public OSRM instances answer 429 above roughly one request a second and say how
|
|
// long to wait. Retrying sooner than that is just a second refusal, so the host's
|
|
// Retry-After wins over the shorter backoff the hook would otherwise use.
|
|
vi.useFakeTimers()
|
|
calculateRouteWithLegs
|
|
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
|
|
.mockResolvedValue(routed(1))
|
|
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
|
|
// The hook's own first backoff is 1500 ms; at that point it must still be waiting.
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
expect(result.current.days[0].legs[0]).toBeDefined()
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-014: a via joins the routing request between the stops it follows', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
|
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
|
}))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// Three waypoints for two stops: the via is threaded in where it belongs.
|
|
const [waypoints] = calculateRouteWithLegs.mock.calls[0]
|
|
expect(waypoints).toHaveLength(3)
|
|
expect(waypoints[1]).toEqual({ lat: 53.2, lng: 10.4 })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-015: the two legs a via creates are shown as the one drive they are', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
// The router answers per waypoint pair: two legs for one stop pair.
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops), 'driving', {
|
|
1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }],
|
|
}))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// The rail shows one leg between two stops, carrying the whole drive.
|
|
expect(result.current.days[0].legs).toHaveLength(1)
|
|
expect(result.current.days[0].legs[0]).toMatchObject({ distance: 100000, duration: 3600 })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-016: moving a via re-asks the router, renaming a place still does not', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53.2, lng: 10.4 }] }
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, days, map(1, stops), 'driving', v),
|
|
{ initialProps: { v: vias } },
|
|
)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
|
|
// A via that moved is a different road, so the route has to be asked for again.
|
|
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.9 }] } })
|
|
await waitFor(() => expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2))
|
|
})
|
|
|
|
/**
|
|
* Days read by the date each stop is REACHED, and the road between them.
|
|
*
|
|
* The arrangement itself is tested in `nightSpill.test.ts`; what is asserted here is
|
|
* what the hook does with it — the roads it has to ask for that a day-at-a-time plan
|
|
* never needed, and what the switch over them turns on.
|
|
*/
|
|
describe('driving that crosses a day boundary', () => {
|
|
/** Two days, the first of which sets off late enough to arrive after midnight. */
|
|
const overnight = () => ({
|
|
days: [day(1, 1), day(2, 2)],
|
|
assignments: {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
|
} as AssignmentsMap,
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-017: routes the road onto the day a night drive lands on', async () => {
|
|
// The stop reached after midnight is drawn under day 2, next to day 2's own first
|
|
// stop. Those two were never neighbours before, so their road was never asked for —
|
|
// and a chain with a hole in it draws as two runs with a gap across the middle.
|
|
const { days, assignments } = overnight()
|
|
// Three hours per leg, which is what puts the second stop past midnight.
|
|
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }] })
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
await waitFor(() => expect(calculateRouteWithLegs.mock.calls.length).toBeGreaterThan(2))
|
|
|
|
// A two-point request, which is the seam rather than either day's own run.
|
|
const seam = calculateRouteWithLegs.mock.calls.find(c => c[0].length === 2
|
|
&& c[0][0].lat === LUENEBURG[0] && c[0][1].lat === BERLIN[0])
|
|
expect(seam).toBeTruthy()
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-018: leaves the gap between two ordinary days alone', async () => {
|
|
// Nothing moved here, so the road from one day's last stop to the next day's first
|
|
// is a gap the plan simply has. Asking for it is what the switch is for.
|
|
const daysList = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
|
} as AssignmentsMap
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// One request per day and no more: the seam is not asked for.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
expect(result.current.days).toHaveLength(2)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-019: hands the map a line per leg, coloured by the day it is driven on', async () => {
|
|
const daysList = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
|
} as AssignmentsMap
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// Same length and same order as `lines`, which is the contract the map paints from.
|
|
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
|
|
expect(result.current.lineDays).toEqual([1, 2])
|
|
})
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-011: leaving the view aborts the request in flight', async () => {
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
let seen: AbortSignal | undefined
|
|
calculateRouteWithLegs.mockImplementation((_wp: unknown, opts: { signal: AbortSignal }) => {
|
|
seen = opts.signal
|
|
return new Promise(() => {}) // never settles
|
|
})
|
|
|
|
const { unmount } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(seen).toBeDefined())
|
|
expect(seen!.aborted).toBe(false)
|
|
|
|
unmount()
|
|
expect(seen!.aborted).toBe(true)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-020: gives each leg its share of the drawn line, not its share of the routed metres', async () => {
|
|
// The router reports 50 km a leg off its own graph while the polyline it sends back
|
|
// measures 260 km as great-circle hops between the vertices. Cutting at the raw
|
|
// metres left every run short by the difference: the second leg ended a third of the
|
|
// way into the drive and the day never reached Berlin at all.
|
|
const days = [day(1, 1)]
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: LUENEBURG }, { id: 3, at: BERLIN }]
|
|
calculateRouteWithLegs.mockResolvedValue(routed(2, [HAMBURG, LUENEBURG, BERLIN]))
|
|
const metres = (line: [number, number][]): number => lineMetres(line.map(([lat, lng]) => ({ lat, lng })))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
const [first, second] = result.current.lines
|
|
expect(first[0]).toEqual(HAMBURG)
|
|
// Both ends are interpolated inside the segment they fall in, so the two pieces meet
|
|
// on one coordinate and put back together give the run back.
|
|
expect(first[first.length - 1]).toEqual(second[0])
|
|
// And the last cut lands on the last vertex rather than somewhere short of it.
|
|
expect(second[second.length - 1][0]).toBeCloseTo(BERLIN[0], 6)
|
|
expect(second[second.length - 1][1]).toBeCloseTo(BERLIN[1], 6)
|
|
// Two legs the router called equal take equal shares of the road that was drawn, and
|
|
// between them the whole 260 km of it rather than the 100 km the router reported.
|
|
const drawn = metres(first) + metres(second)
|
|
expect(metres(first) / drawn).toBeCloseTo(0.5, 2)
|
|
expect(drawn).toBeCloseTo(metres([HAMBURG, LUENEBURG, BERLIN]), -2)
|
|
})
|
|
|
|
/**
|
|
* The switch that turns a trip stored as days into one continuous drive.
|
|
*
|
|
* Off, a card holds only the driving between its own stops: the road across the join is
|
|
* not asked for, not drawn and not counted. On, every one of those gaps becomes a leg
|
|
* like any other — and it is the one stretch of a trip that no day run ever covers, so
|
|
* a hole left in the chain here is left there by nothing else.
|
|
*/
|
|
describe('connecting the days', () => {
|
|
beforeEach(() => {
|
|
// Wholesale, so nothing a previous case set can answer for this one.
|
|
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
|
|
})
|
|
|
|
/** The hook reads the switch off the settings store, the way the limits card writes it. */
|
|
const setting = (over: Partial<Settings>): void => {
|
|
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) })
|
|
}
|
|
|
|
/** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */
|
|
const twoDays = () => ({
|
|
days: [day(1, 1), day(2, 2)],
|
|
assignments: {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
|
} as AssignmentsMap,
|
|
})
|
|
|
|
/** Every request for one particular road, found by the two ends it was asked for. */
|
|
const askedFor = (from: [number, number], to: [number, number]) =>
|
|
calculateRouteWithLegs.mock.calls.filter(c =>
|
|
c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0])
|
|
|
|
/** A run whose legs are a round 100 km each, so the range budget reads off the page. */
|
|
const hundreds = (legs: number, coordinates: [number, number][]) => ({
|
|
coordinates,
|
|
distance: legs * 100000,
|
|
duration: legs * 3600,
|
|
legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })),
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => {
|
|
const { days: daysList, assignments } = twoDays()
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
// A gap the day-at-a-time plan simply has, and the totals say so by leaving it out.
|
|
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0)
|
|
expect(result.current.lines).toHaveLength(2)
|
|
expect(result.current.totalDistance).toBe(200000)
|
|
|
|
setting({ roadtrip_connect_days: true })
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
|
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
|
expect(result.current.totalDistance).toBe(300000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => {
|
|
setting({ roadtrip_connect_days: true })
|
|
const { days: daysList, assignments } = twoDays()
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.lines).toHaveLength(3))
|
|
|
|
// Same length and same order as `lines`, or a colour lands on the wrong day.
|
|
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
|
|
// The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2
|
|
// look like it begins somewhere it has no stop.
|
|
expect(result.current.lineDays).toEqual([1, 1, 2])
|
|
// The kilometres go the other way: they are driven on the day they arrive.
|
|
expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000])
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => {
|
|
// Stored inside one day and real driving, but on screen it runs from a stop on one
|
|
// card to a stop on the next, and a line between two cards is what the switch asks
|
|
// about. Its kilometres belong to the day it lands on either way.
|
|
const daysList = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
|
|
} as AssignmentsMap
|
|
// Three hours a leg, which is what puts the second stop past midnight.
|
|
calculateRouteWithLegs.mockResolvedValue({
|
|
...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }],
|
|
})
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.lines).toHaveLength(2))
|
|
expect(result.current.days[0].distance).toBe(200000)
|
|
|
|
setting({ roadtrip_connect_days: true })
|
|
expect(result.current.lines).toHaveLength(3)
|
|
expect(result.current.lineDays).toEqual([1, 2, 2])
|
|
expect(result.current.days[0].distance).toBe(300000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => {
|
|
setting({ roadtrip_connect_days: true })
|
|
const { days: daysList, assignments } = twoDays()
|
|
// Filed after the last stop of day 1, which is the join itself. Handed over in the
|
|
// wrong order on purpose: driven through its points backwards the seam doubles back.
|
|
const vias = {
|
|
1: [
|
|
{ id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 },
|
|
{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 },
|
|
],
|
|
}
|
|
// A shaped seam comes back as one leg per waypoint pair.
|
|
const inPieces = {
|
|
...routed(1),
|
|
legs: [
|
|
{ distance: 30000, duration: 1000, text: '30 km' },
|
|
{ distance: 30000, duration: 1000, text: '30 km' },
|
|
{ distance: 40000, duration: 1600, text: '40 km' },
|
|
],
|
|
}
|
|
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
|
Promise.resolve(wp.length === 4 ? inPieces : routed(1)))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias))
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
|
|
|
const [waypoints] = askedFor(LUENEBURG, BERLIN)[0]
|
|
expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]])
|
|
// Three pieces, one drive: the rail carries the whole road across the join rather
|
|
// than the first stretch of it.
|
|
await waitFor(() => expect(result.current.days[1].distance).toBe(200000))
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => {
|
|
// The first answer is filed under the pair of stops. Skipping the seam whenever any
|
|
// answer existed is what made a via on the join do visibly nothing at all.
|
|
setting({ roadtrip_connect_days: true })
|
|
const { days: daysList, assignments } = twoDays()
|
|
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] }
|
|
|
|
const { rerender } = renderHook(
|
|
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v),
|
|
{ initialProps: { v: vias } },
|
|
)
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
|
|
|
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } })
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
|
|
const [waypoints] = askedFor(LUENEBURG, BERLIN)[1]
|
|
expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => {
|
|
setting({ roadtrip_connect_days: true })
|
|
const { days: daysList, assignments } = twoDays()
|
|
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
|
|
wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1)))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
// Let the refusal settle: what it must not do is file a leg anyway.
|
|
await act(async () => { await Promise.resolve() })
|
|
|
|
expect(result.current.lines).toHaveLength(2)
|
|
expect(result.current.lineDays).toEqual([1, 2])
|
|
// Partial rather than invented, exactly like any other leg that will not route.
|
|
expect(result.current.totalDistance).toBe(200000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => {
|
|
// A tank does not empty overnight and the join is real driving, so the day it
|
|
// reaches starts with those kilometres already on the clock — which moves the point
|
|
// the fuel runs out a whole leg earlier.
|
|
const SOUTH: [number, number] = [51.5, 13.5]
|
|
const FURTHER_SOUTH: [number, number] = [51.05, 13.74]
|
|
setting({ roadtrip_range_km: 250 })
|
|
const daysList = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
|
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: SOUTH }, { id: 5, at: FURTHER_SOUTH }]),
|
|
} as AssignmentsMap
|
|
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve(
|
|
wp[0].lat === BERLIN[0]
|
|
? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH])
|
|
: hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG])))
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(result.current.days).toHaveLength(2))
|
|
await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1))
|
|
// 100 km carried over from day 1, so the 250th is reached halfway down the second leg.
|
|
expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 })
|
|
|
|
setting({ roadtrip_connect_days: true })
|
|
await waitFor(() => expect(result.current.days[1].distance).toBe(300000))
|
|
const [dry] = result.current.days[1].dryPoints!
|
|
expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 })
|
|
// Walked along the roads this day drives, not along the drawn line: 50 km down that
|
|
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
|
|
expect(dry.lat).toBeLessThan(BERLIN[0])
|
|
setting({ roadtrip_range_km: 150 })
|
|
await waitFor(() => expect(result.current.days[1].dryPoints![0].legIndex).toBe(-1))
|
|
const inboundDry = result.current.days[1].dryPoints![0]
|
|
expect(inboundDry).toMatchObject({ intoLegKm: 50, drivenMeters: 50000, inboundLine: [LUENEBURG, BERLIN] })
|
|
expect(inboundDry.lat).toBeGreaterThan(BERLIN[0])
|
|
expect(inboundDry.lat).toBeLessThan(LUENEBURG[0])
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-049: switching what the trip avoids asks for the join again, with the new classes', async () => {
|
|
// The seam round skipped every join it already had an answer for, whatever that
|
|
// answer had been asked with, so the drive between two days kept its old road and
|
|
// its old minutes until the page was loaded again.
|
|
//
|
|
// The clock the requests are spaced by is held still and moved by hand, so neither
|
|
// round waits for real time between its requests.
|
|
let now = 10_000
|
|
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
try {
|
|
setting({ roadtrip_connect_days: true })
|
|
const { days: daysList, assignments } = twoDays()
|
|
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
|
|
expect(askedFor(LUENEBURG, BERLIN)[0][1]).toMatchObject({ avoid: [] })
|
|
|
|
now += 10_000
|
|
setting({ roadtrip_avoid: 'toll' })
|
|
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
|
|
expect(askedFor(LUENEBURG, BERLIN)[1][1]).toMatchObject({ profile: 'driving', avoid: ['toll'] })
|
|
} finally {
|
|
clock.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('a visit End on the road trip', () => {
|
|
/** An hour from Hamburg to Lueneburg and another on to Berlin. */
|
|
const hourly = () => ({
|
|
coordinates: [HAMBURG, LUENEBURG, BERLIN],
|
|
distance: 200000,
|
|
duration: 7200,
|
|
legs: [{ distance: 100000, duration: 3600, text: '' }, { distance: 100000, duration: 3600, text: '' }],
|
|
})
|
|
|
|
async function drive(stops: StopSpec[]) {
|
|
calculateRouteWithLegs.mockResolvedValue(hourly())
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops)))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
await waitFor(() => expect(result.current.days).toHaveLength(1))
|
|
return result.current.days[0]
|
|
}
|
|
|
|
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
|
|
|
it('FE-ROADTRIP-ROUTES-030: the drive leaves a stop at its End, not after its stay', async () => {
|
|
const d = await drive([
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
|
{ id: 3, at: BERLIN },
|
|
])
|
|
expect(d.stops[1].leaveAt).toBe('14:00')
|
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
|
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
|
expect(d.schedule.warnings).toEqual([])
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-031: an End the place carries counts for a visit without its own', async () => {
|
|
const d = await drive([
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, placeEnd: '13:00', dwell: 30 },
|
|
{ id: 3, at: BERLIN },
|
|
])
|
|
expect(d.schedule.entries[1].departure).toBe('13:00')
|
|
expect(d.schedule.entries[2].arrival).toBe('14:00')
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-032: Start and End together make the stay the time between them', async () => {
|
|
const d = await drive([
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, time: '10:00', end: '14:00', dwell: 60 },
|
|
{ id: 3, at: BERLIN },
|
|
])
|
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00', anchored: true })
|
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-033: a stop reached after its End says so instead of leaving quietly', async () => {
|
|
const d = await drive([
|
|
{ id: 1, at: HAMBURG, time: '13:30', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
|
{ id: 3, at: BERLIN },
|
|
])
|
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '14:30', departure: '14:30' })
|
|
expect(d.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }])
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-034: daily travel times spend the hours until the End as well', async () => {
|
|
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_day_start: '08:00', roadtrip_day_end: '20:00' } }))
|
|
const d = await drive([
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, end: '14:00', dwell: 30 },
|
|
{ id: 3, at: BERLIN },
|
|
])
|
|
expect(d.automaticSchedule).toBe(true)
|
|
expect(d.schedule.entries[1]).toMatchObject({ arrival: '10:00', departure: '14:00' })
|
|
expect(d.schedule.entries[2].arrival).toBe('15:00')
|
|
})
|
|
})
|
|
|
|
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
|
useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) => useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
|
|
}))
|
|
|
|
describe('a booking the traveller rides (#2428)', () => {
|
|
const MUC: [number, number] = [48.3538, 11.7861]
|
|
const HAM: [number, number] = [53.6304, 9.9882]
|
|
const flight = (over: Record<string, unknown> = {}) => ({
|
|
id: 70,
|
|
trip_id: 7,
|
|
title: 'LH 2020 HAM → MUC',
|
|
type: 'flight',
|
|
status: 'confirmed',
|
|
day_id: 1,
|
|
end_day_id: 1,
|
|
reservation_time: '13:20',
|
|
reservation_end_time: '14:30',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Hamburg Airport', code: 'HAM', lat: HAM[0], lng: HAM[1], timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'Munich Airport', code: 'MUC', lat: MUC[0], lng: MUC[1], timezone: null, local_time: null, local_date: null },
|
|
],
|
|
...over,
|
|
}) as unknown as import('../../types').Reservation
|
|
|
|
/** An hour for whatever pair the router is handed. */
|
|
const hourly = (points: { lat: number; lng: number }[]) => ({
|
|
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
|
distance: 100000 * (points.length - 1),
|
|
duration: 3600 * (points.length - 1),
|
|
legs: points.slice(1).map(() => ({ distance: 100000, duration: 3600, text: '' })),
|
|
})
|
|
|
|
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
|
|
|
it('FE-ROADTRIP-ROUTES-040: the terminals seam the day, the ride is never routed, and the roads to and from them are', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
const stops: StopSpec[] = [
|
|
{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 },
|
|
{ id: 2, at: LUENEBURG, time: '10:00', dwell: 0 },
|
|
{ id: 3, at: BERLIN, dwell: 0 },
|
|
]
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [flight()]))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
const d = result.current.days[0]
|
|
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'Stop 2', 'departure', 'arrival', 'Stop 3'])
|
|
// Two road runs went to the router: up to the departure terminal, and from the arrival on.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
const asked = calculateRouteWithLegs.mock.calls.map(c => c[0].map((p: { lat: number }) => p.lat))
|
|
expect(asked).toEqual([[HAMBURG[0], LUENEBURG[0], HAM[0]], [MUC[0], BERLIN[0]]])
|
|
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'flight', 'driving'])
|
|
expect(d.legs[2]).toMatchObject({ distance: 0, duration: 70 * 60 })
|
|
// An hour ahead of the flight at the airport, off on the timetable, landed on it.
|
|
expect(d.schedule.entries.map(e => [e.arrival, e.departure])).toEqual([
|
|
['09:00', '09:00'],
|
|
['10:00', '10:00'],
|
|
['12:20', '13:20'],
|
|
['14:30', '14:30'],
|
|
['15:30', '15:30'],
|
|
])
|
|
// The ride draws no road of its own; the three road legs do.
|
|
expect(result.current.lines).toHaveLength(3)
|
|
expect(result.current.totalStops).toBe(3)
|
|
expect(result.current.totalDistance).toBe(300000)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-041: a ride landing on a later day seams two cards, and with connected days the ride is the join', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
|
|
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
|
|
}
|
|
const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 })
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], [overnight]))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.mode).toBe('flight'))
|
|
expect(result.current.days[0].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['Stop 1', 'departure'])
|
|
expect(result.current.days[1].stops.map(s => s.carrier?.role ?? s.name)).toEqual(['arrival', 'Stop 2'])
|
|
expect(result.current.days[1].schedule.entries[0].arrival).toBe('07:00')
|
|
// The join between the days is the ride, and the router was asked for the two roads only.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
expect(result.current.days[1].arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600, distance: 0 })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-044: an edited timetable changes the ride minutes without a routing round, and the chain is not late for it', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG, time: '09:00', dwell: 0 }, { id: 2, at: BERLIN, dwell: 0 }]
|
|
const { result, rerender } = renderHook(
|
|
({ reservations }) => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], reservations),
|
|
{ initialProps: { reservations: [flight()] } },
|
|
)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.days[0].legs[1]).toMatchObject({ mode: 'flight', duration: 70 * 60 })
|
|
|
|
// The same terminals, the same roads: the landing is now forty minutes after take-off.
|
|
rerender({ reservations: [flight({ reservation_end_time: '14:00' })] })
|
|
await waitFor(() => expect(result.current.days[0].legs[1]?.duration).toBe(40 * 60))
|
|
const d = result.current.days[0]
|
|
expect(d.legs[1]?.durationText).toBe('40 min')
|
|
expect(d.schedule.entries.map(e => e.arrival)).toEqual(['09:00', '12:20', '14:00', '15:00'])
|
|
expect(d.schedule.warnings.filter(w => w.code === 'late')).toEqual([])
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-045: an overnight ride is the join between its cards, and a moved landing moves it too', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS, roadtrip_connect_days: true } }))
|
|
const days = [day(1, 1), day(2, 2)]
|
|
const assignments = {
|
|
...map(1, [{ id: 1, at: HAMBURG, time: '18:00', dwell: 0 }]),
|
|
...map(2, [{ id: 2, at: BERLIN, dwell: 0 }]),
|
|
}
|
|
const overnight = (landing: string) => flight({ reservation_time: '22:00', reservation_end_time: landing, end_day_id: 2 })
|
|
const { result, rerender } = renderHook(
|
|
({ reservations }) => useRoadtripRoutes(7, days, assignments, 'driving', {}, [], [], reservations),
|
|
{ initialProps: { reservations: [overnight('07:00')] } },
|
|
)
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.days[1]?.arrivingLeg).toMatchObject({ mode: 'flight', duration: 9 * 3600 })
|
|
// The ride is derived, not fetched: the router saw the two roads and nothing else.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
rerender({ reservations: [overnight('06:30')] })
|
|
await waitFor(() => expect(result.current.days[1]?.arrivingLeg?.duration).toBe(8.5 * 3600))
|
|
expect(result.current.days[1].schedule.entries[0].arrival).toBe('06:30')
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-042: a booking without located terminals, or a taxi, changes nothing', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
const stops: StopSpec[] = [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]
|
|
const unlocated = flight({ endpoints: [] })
|
|
const taxi = flight({ id: 71, type: 'taxi' })
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [unlocated, taxi]))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(result.current.days[0].stops.every(s => !s.carrier)).toBe(true)
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-043: a hire car puts its desks on the road, one run through both, and no ride', async () => {
|
|
calculateRouteWithLegs.mockImplementation(async (points: { lat: number; lng: number }[]) => hourly(points))
|
|
const stops: StopSpec[] = [{ id: 1, at: LUENEBURG, time: '10:00', dwell: 0 }, { id: 2, at: BERLIN, time: '14:00', dwell: 0 }]
|
|
const hireCar = flight({
|
|
id: 71,
|
|
type: 'car',
|
|
title: 'Sixt',
|
|
reservation_time: '09:00',
|
|
reservation_end_time: '18:00',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Sixt Hamburg', code: null, lat: HAMBURG[0], lng: HAMBURG[1], timezone: null, local_time: null, local_date: null },
|
|
{ role: 'to', sequence: 1, name: 'Sixt Berlin', code: null, lat: BERLIN[0] + 0.01, lng: BERLIN[1], timezone: null, local_time: null, local_date: null },
|
|
],
|
|
})
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, stops), 'driving', {}, [], [], [hireCar]))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
const d = result.current.days[0]
|
|
expect(d.stops.map(s => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 1', 'Stop 2', 'return'])
|
|
// One road, the whole day, desk to desk.
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
|
expect(d.legs.map(l => l?.mode)).toEqual(['driving', 'driving', 'driving'])
|
|
expect(d.schedule.entries[0]).toMatchObject({ arrival: '09:00', departure: '09:00' })
|
|
expect(result.current.totalStops).toBe(2)
|
|
expect(result.current.lines).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* One leg asked for again, the way the rail asks for it.
|
|
*
|
|
* The picker of other ways and the check behind a choice both route a single leg on
|
|
* demand. Asked by anything but the rail's own rules, the offers described a drive the rail
|
|
* was not on: OSRM with nothing avoided beside a day Valhalla had weighed, under the trip's
|
|
* profile rather than the leg's own mode.
|
|
*/
|
|
describe('a leg asked for again on demand', () => {
|
|
beforeEach(() => {
|
|
useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })
|
|
})
|
|
afterEach(() => act(() => useSettingsStore.setState({ settings: { ...DEFAULT_SETTINGS } })))
|
|
|
|
const stopOn = (ownerDayId: number, ownerIndex: number, at: [number, number], over: Partial<RoadtripStop> = {}) =>
|
|
({ assignmentId: ownerDayId * 10 + ownerIndex, ownerDayId, ownerIndex, lat: at[0], lng: at[1], legMode: null, incomingLegMode: null, ...over }) as RoadtripStop
|
|
|
|
it('FE-ROADTRIP-ROUTES-046: a leg inside one day is asked with that day\'s mode, the trip\'s classes and its own day', async () => {
|
|
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll' } })) })
|
|
const days = [day(1, 1, { default_transport_mode: 'driving' })]
|
|
// The trip-wide profile says walking; the day's own default is what the run used.
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }]), 'walking'))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
expect(calculateRouteWithLegs.mock.calls[0][1]).toMatchObject({ profile: 'driving', avoid: ['toll'], dayId: 1 })
|
|
|
|
const [from, to] = result.current.days[0].stops
|
|
const router = result.current.legRouter!(from, to, 1)
|
|
expect(router).toMatchObject({ mode: 'driving', avoid: ['toll'], engine: 'valhalla' })
|
|
|
|
calculateRouteWithLegs.mockClear()
|
|
calculateRouteWithLegs.mockResolvedValue({ ...routed(2), hasFerry: true })
|
|
const answer = await router.route([{ lat: 53, lng: 11 }], new AbortController().signal)
|
|
|
|
expect(calculateRouteWithLegs).toHaveBeenCalledWith(
|
|
[{ lat: HAMBURG[0], lng: HAMBURG[1] }, { lat: 53, lng: 11 }, { lat: BERLIN[0], lng: BERLIN[1] }],
|
|
expect.objectContaining({ profile: 'driving', tripId: 7, dayId: 1, avoid: ['toll'] }),
|
|
)
|
|
expect(answer).toMatchObject({ coordinates: [HAMBURG, BERLIN], distance: 100000, duration: 3600, hasFerry: true, fellBack: false })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-047: a seam is asked under the card it arrives on, and a leg nothing weighs is OSRM\'s', () => {
|
|
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'motorway' } })) })
|
|
const days = [day(1, 1, { default_transport_mode: 'cycling' }), day(2, 2, { default_transport_mode: 'driving' })]
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, days, {} as AssignmentsMap))
|
|
|
|
const lastOfDay1 = stopOn(1, 3, LUENEBURG)
|
|
const firstOfDay2 = stopOn(2, 0, BERLIN)
|
|
// Across the seam: day 2's default, so the car and the classes it avoids.
|
|
expect(result.current.legRouter!(lastOfDay1, firstOfDay2, 2)).toMatchObject({ mode: 'driving', avoid: ['motorway'], engine: 'valhalla' })
|
|
// Inside day 1: day 1's bicycle, which nothing is weighed against.
|
|
expect(result.current.legRouter!(stopOn(1, 2, HAMBURG), lastOfDay1, 2)).toMatchObject({ mode: 'cycling', avoid: [], engine: 'osrm' })
|
|
// A stop's own mode wins over any default, and a plugin prices its own leg.
|
|
expect(result.current.legRouter!(stopOn(2, 0, BERLIN, { legMode: 'plugin:ev/fast' }), stopOn(2, 1, HAMBURG), 2))
|
|
.toMatchObject({ mode: 'plugin:ev/fast', avoid: [], engine: 'plugin' })
|
|
})
|
|
|
|
it('FE-ROADTRIP-ROUTES-048: a day the stand-in engine drove while avoiding says the avoidance did not happen', async () => {
|
|
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll,ferry' } })) })
|
|
calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['ferry', 'toll'], achieved: [], fellBack: true } })
|
|
|
|
const { result } = renderHook(() => useRoadtripRoutes(7, [day(1, 1)], map(1, [{ id: 1, at: HAMBURG }, { id: 2, at: BERLIN }])))
|
|
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
|
|
expect(result.current.days[0].avoidMissed).toEqual(['ferry', 'toll'])
|
|
})
|
|
})
|