From 91801b10c019ae263d2e4291a20ee0f89edbd246 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 7 Sep 2026 17:30:55 +0200 Subject: [PATCH] test(roadtrip): cover the five client pieces that had no test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Roadtrip/RoadtripAlternativesBar.test.tsx | 145 +++++++++++++++++ .../Roadtrip/RoadtripCategoryPicker.test.tsx | 93 +++++++++++ .../Roadtrip/RoadtripStayModal.test.tsx | 143 +++++++++++++++++ .../Roadtrip/StopKindPicker.test.tsx | 117 ++++++++++++++ .../Roadtrip/useRouteAlternatives.test.ts | 150 ++++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripStayModal.test.tsx create mode 100644 client/src/components/Roadtrip/StopKindPicker.test.tsx create mode 100644 client/src/components/Roadtrip/useRouteAlternatives.test.ts diff --git a/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx b/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx new file mode 100644 index 000000000..e1e299513 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripAlternativesBar.test.tsx @@ -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 => ({ + 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 => ({ + 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( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => { + render( + , + ) + expect(screen.getByText(/asking the router/i)).toBeInTheDocument() + }) + + it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => { + render( + , + ) + 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( + , + ) + expect(screen.getByText(/only/i)).toBeInTheDocument() + }) + + it('FE-ALTBAR-005: each offer shows its distance and what it is', () => { + render( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + fireEvent.click(screen.getByRole('button', { name: /close/i })) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx b/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx new file mode 100644 index 000000000..9c7117392 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripCategoryPicker.test.tsx @@ -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() + 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') + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx new file mode 100644 index 000000000..138a1af1b --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx @@ -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 => ({ + placeId: 9, + name: 'Rasthof Dammer Berge', + minutes: null, + arrival: '12:00', + ...over, +}) + +function open(over: Partial = {}, onSave = vi.fn(), onClose = vi.fn()) { + render() + 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() + 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(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() + }) +}) diff --git a/client/src/components/Roadtrip/StopKindPicker.test.tsx b/client/src/components/Roadtrip/StopKindPicker.test.tsx new file mode 100644 index 000000000..7680bb1d0 --- /dev/null +++ b/client/src/components/Roadtrip/StopKindPicker.test.tsx @@ -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( + , + ) + return { onPick, onClose } +} + +describe('StopKindPicker', () => { + it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => { + const onPick = vi.fn() + render() + 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) + }) +}) diff --git a/client/src/components/Roadtrip/useRouteAlternatives.test.ts b/client/src/components/Roadtrip/useRouteAlternatives.test.ts new file mode 100644 index 000000000..4b2db3f24 --- /dev/null +++ b/client/src/components/Roadtrip/useRouteAlternatives.test.ts @@ -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 = {}) => ({ + 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() + }) +})