mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user