test(roadtrip): cover the five client pieces that had no test

Five files, about 570 lines, that shipped without one. The cases are chosen for
what a later change could break quietly rather than for the line count.

useRouteAlternatives carries the decision the feature rests on: when a leg
already has vias, the road actually being driven is NOT among what the router
offers for the two bare endpoints, because it was asked a different question. It
is fetched separately and put at the top — without that, opening the picker on a
leg you have reshaped shows three roads, none of which is the one you are on and
no way back to it. Also pinned: a via that barely moves the route produces one
entry rather than two, a second ask abandons the answer still in flight, and only
driving, walking and cycling ever reach the router.

RoadtripStayModal writes `places.duration_minutes`, which the schedule has read
for years and nothing could write. The case that matters is that clearing sends a
zero and never a null: the update statement folds a null into "leave it alone",
so a null could give a stop a stay and never take one away. The clock cases pin
the thing a slider is for over a text box, which is watching the departure move.

StopKindPicker is a portal, so it gets the cases a portal makes easy to get
wrong: nothing without an anchor, outside-click and Escape both closing, the
anchor itself not counting as outside, and picking the kind a stop already has
taking it back off rather than setting it twice.

RoadtripCategoryPicker stays open on a click, which is the whole reason it is
multi-select, and names the kinds rather than counting them.

RoadtripAlternativesBar reports each offer by its own index rather than by its
position — a position drifts the moment the driven road is prepended — and shows
loading, failed and "only one way" as three different answers instead of an
empty list.
This commit is contained in:
Maurice
2026-09-07 17:30:55 +02:00
committed by Maurice
parent b2d9b25bb6
commit 91801b10c0
5 changed files with 648 additions and 0 deletions
@@ -0,0 +1,145 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
import type { AlternativeOverlay } from './alternativeOverlays'
import type { LegAlternatives } from './useRouteAlternatives'
/**
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
*
* Every value shown comes from the same overlay the map draws. Working them out
* here separately is what once made the list label the driven route "Fastest"
* while offering another one as quicker beside it, because it assumed the first
* entry was the quickest — which stopped being true the moment the road
* currently driven was put at the top.
*/
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
index: 0,
coordinates: [],
color: '#0a84ff',
label: '3 h',
note: '',
duration: 10_800,
distance: 290_000,
slowerThanQuickest: 0,
labelBg: '#0a84ff',
at: { lat: 53, lng: 11 },
...over,
})
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
})
beforeEach(() => {
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
})
describe('RoadtripAlternativesBar', () => {
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
const { container } = render(
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(container).toBeEmptyDOMElement()
})
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
})
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
render(
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
})
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
// Offering a single option would ask the traveller to pick the road they are
// already on.
render(
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.getByText(/only/i)).toBeInTheDocument()
})
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('Fastest')).toBeInTheDocument()
expect(screen.getByText('No motorway')).toBeInTheDocument()
expect(screen.getByText(/290/)).toBeInTheDocument()
expect(screen.getByText(/310/)).toBeInTheDocument()
})
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText(/30/)).toBeInTheDocument()
})
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
// The map and the list agree on `index`; a position would drift the moment
// the driven road is prepended.
const onChoose = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
onChoose={onChoose}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByRole('button')[2])
expect(onChoose).toHaveBeenCalledWith(7)
})
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
const onHighlight = vi.fn()
render(
<RoadtripAlternativesBar
open={leg()}
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
onChoose={vi.fn()}
onClose={vi.fn()}
onHighlight={onHighlight}
/>,
)
const first = screen.getAllByRole('button')[1]
fireEvent.mouseEnter(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
fireEvent.mouseLeave(first)
expect(onHighlight).toHaveBeenLastCalledWith(null)
// Keyboard too: the same road lights up on focus.
fireEvent.focus(first)
expect(onHighlight).toHaveBeenLastCalledWith(0)
})
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
const onClose = vi.fn()
render(
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
)
fireEvent.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,93 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { Fuel, Zap, Utensils } from 'lucide-react'
import { fireEvent, render, screen, within } from '../../../tests/helpers/render'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
/**
* FE-CATPICK-001..008 — what the corridor is asked to look for.
*
* Multi-select, so it stays open on a click: picking "fuel and charging" is one
* gesture rather than two round trips through a menu. The summary names the
* kinds rather than counting them, because "Fuel, Charging" is the answer and
* "2 kinds" is a riddle.
*/
const KEYS = ['fuel', 'charging', 'food'] as const
const META = {
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
food: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
}
function picker(selected: string[] = [], onToggle = vi.fn()) {
render(<RoadtripCategoryPicker keys={KEYS} meta={META} selected={selected} onToggle={onToggle} />)
return { onToggle, trigger: () => screen.getByRole('button', { expanded: false }) }
}
/** The control that opens the list, whatever its current state. */
const trigger = () => screen.getAllByRole('button')[0]
describe('RoadtripCategoryPicker', () => {
it('FE-CATPICK-001: closed by default, so the column belongs to the results', () => {
picker()
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-002: with nothing picked it asks rather than reporting an empty set', () => {
picker()
expect(screen.getByText('Looking for')).toBeInTheDocument()
})
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
// Reversed on the way in: the control lists them the way it offers them, not
// the way they happened to be clicked.
picker(['food', 'fuel'])
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
})
it('FE-CATPICK-004: opening lists every kind', () => {
picker()
fireEvent.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
for (const label of ['Fuel', 'Charging', 'Food']) {
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
}
})
it('FE-CATPICK-005: picking one reports it and leaves the list open', () => {
// The whole reason it is multi-select: two kinds is one gesture.
const { onToggle } = picker()
fireEvent.click(trigger())
fireEvent.click(screen.getByText('Charging'))
expect(onToggle).toHaveBeenCalledWith('charging')
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
})
it('FE-CATPICK-006: picking a kind that is already on reports it too, for the toggle', () => {
const { onToggle } = picker(['fuel'])
fireEvent.click(trigger())
fireEvent.click(screen.getAllByText('Fuel')[1] ?? screen.getByText('Fuel'))
expect(onToggle).toHaveBeenCalledWith('fuel')
})
it('FE-CATPICK-007: a click outside closes it, a click inside does not', () => {
picker()
fireEvent.click(trigger())
fireEvent.mouseDown(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
fireEvent.mouseDown(document.body)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('FE-CATPICK-008: Escape closes it', () => {
picker()
fireEvent.click(trigger())
fireEvent.keyDown(document, { key: 'Escape' })
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
})
@@ -0,0 +1,143 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
/**
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
*
* The value is `places.duration_minutes`, which the schedule has read for years
* and nothing could write. Two things are worth pinning: that clearing sends a
* zero rather than a null — the update statement folds a null into "leave it
* alone", so a null could give a stop a stay but never take one away — and that
* the dialog answers the question the number is chosen for, which is when the
* drive leaves again.
*/
const draft = (over: Partial<StayDraft> = {}): StayDraft => ({
placeId: 9,
name: 'Rasthof Dammer Berge',
minutes: null,
arrival: '12:00',
...over,
})
function open(over: Partial<StayDraft> = {}, onSave = vi.fn(), onClose = vi.fn()) {
render(<RoadtripStayModal stop={draft(over)} onClose={onClose} onSave={onSave} />)
return { onSave, onClose }
}
const slider = () => screen.getByRole('slider') as HTMLInputElement
beforeEach(() => {
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
})
describe('RoadtripStayModal', () => {
it('FE-STAYMODAL-001: no stop means no dialog', () => {
const { container } = render(<RoadtripStayModal stop={null} onClose={vi.fn()} onSave={vi.fn()} />)
expect(container).toBeEmptyDOMElement()
})
it('FE-STAYMODAL-002: it opens on the stop own length, not on the last one looked at', () => {
open({ minutes: 45 })
expect(slider().value).toBe('45')
})
it('FE-STAYMODAL-003: a stop with no stay opens at nothing rather than at a guess', () => {
open({ minutes: null })
expect(slider().value).toBe('0')
expect(screen.getAllByText(/no stay|none/i).length).toBeGreaterThan(0)
})
it('FE-STAYMODAL-004: it answers the question the number is chosen for', () => {
// The arrival is fixed by the drive; the departure is the only thing this
// dialog moves, and seeing it move is the point of a slider over a box.
open({ minutes: 30, arrival: '12:00' })
expect(screen.getByText('12:00')).toBeInTheDocument()
expect(screen.getByText('12:30')).toBeInTheDocument()
fireEvent.change(slider(), { target: { value: '90' } })
expect(screen.getByText('13:30')).toBeInTheDocument()
})
it('FE-STAYMODAL-005: a stop the drive has not timed shows no clock at all', () => {
// Better than inventing one: a departure without an arrival is a number with
// nothing behind it.
open({ minutes: 30, arrival: null })
expect(screen.queryByText(/\d\d:\d\d/)).not.toBeInTheDocument()
})
it('FE-STAYMODAL-006: the clock follows the reader own format', () => {
useSettingsStore.setState({ settings: { time_format: '12h' } as never })
open({ minutes: 60, arrival: '13:00' })
expect(screen.getByText(/1:00\s*PM/i)).toBeInTheDocument()
expect(screen.getByText(/2:00\s*PM/i)).toBeInTheDocument()
})
it('FE-STAYMODAL-007: the two buttons nudge, and stop at the ends', () => {
open({ minutes: 0 })
// Nothing below zero: the minus is off rather than silently doing nothing.
expect(screen.getByRole('button', { name: '-5' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '+5' }))
expect(slider().value).toBe('5')
fireEvent.click(screen.getByRole('button', { name: '-5' }))
expect(slider().value).toBe('0')
})
it('FE-STAYMODAL-008: the presets are one tap, which is the point of them', () => {
open({ minutes: 0 })
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
expect(slider().value).toBe('60')
})
it('FE-STAYMODAL-009: saving reports the stop and the chosen length, then closes', async () => {
const { onSave, onClose } = open({ minutes: 0 })
fireEvent.change(slider(), { target: { value: '45' } })
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 45))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('FE-STAYMODAL-010: clearing sends a zero, never a null', async () => {
// The update statement folds a null into "leave it alone", so a null could
// give a stop a stay and never take one away.
const { onSave } = open({ minutes: 90 })
fireEvent.click(screen.getByRole('button', { name: /no stay|none/i }))
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 0))
expect(onSave.mock.calls[0][1]).not.toBeNull()
})
it('FE-STAYMODAL-011: a save already running is not started twice', async () => {
// Two clicks on Save wrote the same value twice and closed on the first
// answer, which on a slow connection is easy to do.
let release: () => void = () => {}
const onSave = vi.fn(() => new Promise<void>(r => { release = r }))
open({ minutes: 30 }, onSave as never)
const save = screen.getByRole('button', { name: /^save$/i })
fireEvent.click(save)
fireEvent.click(save)
expect(onSave).toHaveBeenCalledTimes(1)
release()
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))
})
it('FE-STAYMODAL-012: cancelling changes nothing', () => {
const { onSave, onClose } = open({ minutes: 30 })
fireEvent.change(slider(), { target: { value: '120' } })
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
})
@@ -0,0 +1,117 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import StopKindPicker from './StopKindPicker'
import { STOP_KINDS } from './stopKinds'
/**
* FE-STOPKIND-001..009 — turning a place on the drive into a pause, and back.
*
* Opened from the stop's own number, because the number is what changes: a
* service stop has none. The popover is a portal hung under that element, so
* what is worth pinning is the part a portal makes easy to get wrong — that it
* renders nothing without an anchor, that clicking outside and Escape both close
* it, and that picking the kind a stop already has takes it back off rather than
* setting it twice.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
})
afterEach(() => { anchor.remove() })
function open(over: { current?: string | null; onPick?: (k: string | null) => void; onClose?: () => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = over.onClose ?? vi.fn()
render(
<StopKindPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick as never}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
describe('StopKindPicker', () => {
it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => {
const onPick = vi.fn()
render(<StopKindPicker anchor={null} current={null} onPick={onPick} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPKIND-002: every kind the rail draws is offered, as a disc rather than a word', () => {
open()
const dialog = screen.getByRole('dialog')
// One button per kind, plus none extra: six rows of text for six icons would
// be a menu where a palette does.
const buttons = dialog.querySelectorAll('button[aria-pressed]')
expect(buttons).toHaveLength(STOP_KINDS.length)
})
it('FE-STOPKIND-003: picking a kind reports it', () => {
const { onPick } = open()
const first = STOP_KINDS[0]
fireEvent.click(screen.getAllByRole('button', { pressed: false })[0])
expect(onPick).toHaveBeenCalledWith(first.key)
})
it('FE-STOPKIND-004: picking the kind it already is takes it back off', () => {
// The control is its own toggle. Without this, changing your mind about a
// fuel stop means finding the separate undo below.
const kind = STOP_KINDS[0].key
const { onPick } = open({ current: kind })
fireEvent.click(screen.getByRole('button', { pressed: true }))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-005: the current kind is the pressed one, and it is the only one', () => {
open({ current: STOP_KINDS[1].key })
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
})
it('FE-STOPKIND-006: the way back to a destination appears only once there is one', () => {
const { onPick } = open({ current: STOP_KINDS[0].key })
const back = screen.getByRole('button', { name: /destination/i })
fireEvent.click(back)
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPKIND-007: on an ordinary place it would say "leave everything as it is", so it is absent', () => {
open({ current: null })
expect(screen.queryByRole('button', { name: /destination/i })).not.toBeInTheDocument()
})
it('FE-STOPKIND-008: a click outside closes it, a click inside does not', () => {
const { onClose } = open()
fireEvent.mouseDown(screen.getByRole('dialog'))
expect(onClose).not.toHaveBeenCalled()
// The anchor is not "outside" either: clicking the number again is what
// toggles the picker, and closing here would fight that.
fireEvent.mouseDown(anchor)
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('FE-STOPKIND-009: Escape closes it, and so does scrolling the rail underneath', () => {
const { onClose } = open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// Following the anchor would mean measuring on every frame for a menu that
// is open for two seconds; closing is the cheaper honest answer.
fireEvent.scroll(window)
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,150 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { calculateAlternatives, calculateRoute } = vi.hoisted(() => ({
calculateAlternatives: vi.fn(),
calculateRoute: vi.fn(),
}))
vi.mock('../Map/RouteCalculator', () => ({ calculateAlternatives, calculateRoute }))
import { useRouteAlternatives } from './useRouteAlternatives'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { RoadtripVia } from '@trek/shared'
/**
* FE-ALTHOOK-001..009 — asking for other ways of driving one leg.
*
* The case that carries the feature: when the leg already carries vias, the road
* actually being driven is NOT among what the router offers for the two bare
* endpoints, because it was asked a different question. Without fetching it
* separately, opening the picker on a leg you have already reshaped shows three
* roads, none of which is the one you are on, and no way back to it.
*/
const from = { lat: 53.55, lng: 9.99 } as RoadtripStop
const to = { lat: 52.52, lng: 13.4 } as RoadtripStop
const via = (lat: number, lng: number): RoadtripVia =>
({ id: 1, day_id: 4, after_order_index: 0, sequence: 0, lat, lng })
const route = (over: Record<string, unknown> = {}) => ({
coordinates: [[53.55, 9.99], [52.52, 13.4]],
distance: 290_000,
duration: 10_800,
divergence: { lat: 53, lng: 11 },
...over,
})
beforeEach(() => {
calculateAlternatives.mockReset().mockResolvedValue([route(), route({ distance: 310_000, duration: 11_400 })])
calculateRoute.mockReset().mockResolvedValue(route({ distance: 340_000, duration: 12_600 }))
})
describe('useRouteAlternatives', () => {
it('FE-ALTHOOK-001: nothing is open until somebody asks', () => {
const { result } = renderHook(() => useRouteAlternatives())
expect(result.current.open).toBeNull()
expect(calculateAlternatives).not.toHaveBeenCalled()
})
it('FE-ALTHOOK-002: asking opens on a loading state, then fills it', async () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
expect(result.current.open).toMatchObject({ dayId: 4, index: 1, loading: true, routes: [] })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes).toHaveLength(2)
// The router's own first answer is the direct one, which is what "no detour
// at all" means when it is chosen.
expect(result.current.open?.routes[0].direct).toBe(true)
expect(result.current.open?.routes[1].direct).toBe(false)
})
it('FE-ALTHOOK-003: a bare leg is not asked for the road it is already driving', () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
expect(calculateRoute).not.toHaveBeenCalled()
})
it('FE-ALTHOOK-004: a bent leg puts the road being driven at the top', async () => {
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes[0]).toMatchObject({ current: true, distance: 340_000 })
expect(result.current.open?.routes).toHaveLength(3)
// Routed through the via, not between the bare ends.
expect(calculateRoute).toHaveBeenCalledWith(
[{ lat: 53.55, lng: 9.99 }, { lat: 53, lng: 11.5 }, { lat: 52.52, lng: 13.4 }],
'driving',
expect.anything(),
)
})
it('FE-ALTHOOK-005: a via that barely moves the route is one road, not two', async () => {
// Two lines are the same road if their ends and their length agree closely
// enough. Listing it twice offers a choice that is not one.
calculateRoute.mockResolvedValue(route({ distance: 290_020, duration: 10_810 }))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.routes).toHaveLength(2)
expect(result.current.open?.routes.some(r => r.current)).toBe(false)
})
it('FE-ALTHOOK-006: the driven road failing costs that entry, not the dialog', async () => {
calculateRoute.mockRejectedValue(new Error('osrm down'))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', [via(53, 11.5)]) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open?.error).toBe(false)
expect(result.current.open?.routes).toHaveLength(2)
})
it('FE-ALTHOOK-007: a router that will not answer says so instead of showing nothing', async () => {
calculateAlternatives.mockRejectedValue(new Error('osrm down'))
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
await waitFor(() => expect(result.current.open?.loading).toBe(false))
expect(result.current.open).toMatchObject({ error: true, routes: [] })
})
it('FE-ALTHOOK-008: only driving, walking and cycling reach the router', () => {
const { result } = renderHook(() => useRouteAlternatives())
for (const [asked, sent] of [['walking', 'walking'], ['cycling', 'cycling'], ['transit', 'driving'], ['plugin:ferry', 'driving']]) {
calculateAlternatives.mockClear()
act(() => { result.current.ask(4, 1, from, to, asked, []) })
expect(calculateAlternatives.mock.calls[0][2], asked).toBe(sent)
}
})
it('FE-ALTHOOK-009: asking again abandons the answer still in flight', async () => {
// Two clicks in a row must not let the first answer land on top of the
// second: the picker would then describe a leg nobody is looking at.
const signals: AbortSignal[] = []
calculateAlternatives.mockImplementation((_a: unknown, _b: unknown, _p: unknown, opts: { signal: AbortSignal }) => {
signals.push(opts.signal)
return new Promise(() => {})
})
const { result } = renderHook(() => useRouteAlternatives())
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
act(() => { result.current.ask(4, 2, from, to, 'driving', []) })
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
expect(result.current.open?.index).toBe(2)
act(() => { result.current.close() })
expect(signals[1].aborted).toBe(true)
expect(result.current.open).toBeNull()
})
})