diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
index dfd206cfe..426edcdfa 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
@@ -5,6 +5,9 @@ import { TranslationProvider } from '../../i18n'
import RoadtripSidebar from './RoadtripSidebar'
import RoadtripModeSwitch from './RoadtripModeSwitch'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
+import type { DryPoint } from './roadtripModel'
+import type { RefuelSearch } from './useRefuelSearch'
+import type { RefuelCandidate, RefuelOutcome } from './refuelSuggestion'
import type { RouteSegment } from '../../types'
const wrap = (ui: React.ReactElement) => render({ui})
@@ -643,6 +646,376 @@ describe('RoadtripSidebar', () => {
expect(screen.queryByText(/From day/)).toBeNull()
})
})
+
+ /**
+ * The band where the fuel actually ends, and the one search offered from it.
+ *
+ * It hangs off the leg rather than off a stop, because those are two different places:
+ * the range warning marks where somebody finds out, and that can be a long way past the
+ * point the tank ran dry. A station offered at the warning is one the car cannot reach.
+ */
+ describe('the leg the tank runs out on', () => {
+ const dry = (over: Partial = {}): DryPoint & { lat: number; lng: number } => ({
+ legIndex: 0,
+ intoLegKm: 182,
+ drivenMeters: 182000,
+ // The range that was crossed, kept deliberately unlike `intoLegKm`: it is the
+ // traveller's own setting, so a band printing it would read the same figure on
+ // every leg of the day.
+ sinceKm: 600,
+ lat: 52.4,
+ lng: 10.2,
+ ...over,
+ })
+
+ /** Idle unless a case says which part of the search it is standing in. */
+ const search = (over: Partial = {}): RefuelSearch => ({
+ openFor: null,
+ loading: false,
+ outcome: null,
+ results: [],
+ offered: [],
+ ask: vi.fn(),
+ close: vi.fn(),
+ ...over,
+ })
+
+ const pump = (name: string, offRouteKm: number, spareKm: number): RefuelCandidate => ({
+ osm_id: `osm-${name}`,
+ name,
+ lat: 52.4,
+ lng: 10.1,
+ category: 'fuel',
+ poi_type: 'fuel',
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours: null,
+ cuisine: null,
+ source: 'openstreetmap',
+ alongKm: 170,
+ offRouteKm,
+ spareKm,
+ })
+
+ const empties = dry({ legIndex: 1 })
+ // Three stops, so the leg that empties has one it is not on to be absent from.
+ const thirsty = (): RoadtripDay => day({
+ stops: [
+ stop({ assignmentId: 1, name: 'Hamburg' }),
+ stop({ assignmentId: 2, name: 'Hannover' }),
+ stop({ assignmentId: 3, name: 'Kassel' }),
+ ],
+ legs: [leg(), leg()],
+ dryPoints: [empties],
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-035: the band sits on the leg the fuel ends on, and says how far into it', () => {
+ const { container } = wrap(
+ ,
+ )
+
+ // The second row owns the second leg. Drawn on every row instead, the rail would
+ // offer a fill-up on a stretch the car drives with a full tank.
+ const rows = container.querySelectorAll('li')
+ expect(screen.getAllByText('Tank runs out here')).toHaveLength(1)
+ expect(within(rows[1] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
+ // Distance into the leg, which is where a drive band keeps its figures. The 600 is
+ // the setting that was crossed and belongs to no place on the map.
+ expect(screen.getByText('after 182 km')).toBeInTheDocument()
+ expect(screen.queryByText(/600 km/)).not.toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-036: the low-fuel lamp is itself the button that goes looking', () => {
+ const onAskRefuel = vi.fn()
+ wrap(
+ ,
+ )
+
+ // Not a lamp with a magnifier beside it, which says the same thing twice: the lamp
+ // reports the empty tank and pressing it is what does something about it, so the
+ // lamp has to be inside the button rather than next to one.
+ const ask = screen.getByRole('button', { name: 'Find fuel' })
+ expect(ask.querySelector('svg.lucide-fuel')).toBeInTheDocument()
+ expect(within(ask).queryByRole('button')).toBeNull()
+
+ fireEvent.click(ask)
+ // The dry point travels with the ask: the search is a circle pulled back from that
+ // coordinate, and the day alone does not say where on it to look.
+ expect(onAskRefuel).toHaveBeenCalledWith(1, empties)
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-037: a band inside a borrowed stretch is filed under the card it is drawn on', () => {
+ const onAskRefuel = vi.fn()
+ const overnight = dry({ legIndex: 0 })
+ const card = day({
+ dayNumber: 2,
+ stops: [
+ stop({ assignmentId: 1, name: 'Neuruppin', ownerDayId: 9, ownerIndex: 3 }),
+ stop({ assignmentId: 2, name: 'Wittenberg' }),
+ ],
+ dryPoints: [overnight],
+ spills: [{
+ at: 0,
+ count: 1,
+ fromDayNumber: 1,
+ departure: '22:30',
+ leg: leg(),
+ line: [[53, 10], [52, 11]],
+ fromStop: stop({ assignmentId: 99, name: 'Circle K', ownerDayId: 9, ownerIndex: 2 }),
+ }],
+ })
+ const { unmount } = wrap(
+ ,
+ )
+
+ // Everything that WRITES names the day a stop is stored on, day 9 here. This is the
+ // exception and it has to be: the open search is filed under the card day and the
+ // leg, so a band asking as day 9 would have its answers arrive under a key no band
+ // is watching and would never open. That it opened at all is the assertion.
+ expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
+
+ unmount()
+ wrap(
+ ,
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
+ expect(onAskRefuel).toHaveBeenCalledWith(1, overnight)
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-038: while the request is in flight the lamp steps aside for the way out', () => {
+ const close = vi.fn()
+ wrap(
+ ,
+ )
+
+ expect(screen.getByText(/Looking along the route/)).toBeInTheDocument()
+ // Gone, not merely quieter. Every press is a real request against a shared service,
+ // and a lamp still standing there invites a second one nobody asked for.
+ expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(close).toHaveBeenCalled()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-039: an answer carries the detour and what is left in the tank as two figures', () => {
+ const onAcceptRefuel = vi.fn()
+ const shell = pump('Shell Hannover', 1.4, 48.6)
+ wrap(
+ ,
+ )
+
+ // Two facts about two different things: the detour is what the stop costs and what
+ // the list is sorted by, the spare is what is left when the car draws level. Joined
+ // into one line with a separator, neither exact lookup finds anything.
+ expect(screen.getByText('1.4 km')).toBeInTheDocument()
+ expect(screen.getByText('49 km')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add Shell Hannover as a fuel stop' }))
+ // With the dry point, because the caller has no other way of knowing which leg the
+ // accepted stop belongs on.
+ expect(onAcceptRefuel).toHaveBeenCalledWith(1, shell, empties)
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-040: a search that came back with nothing says which nothing it was', () => {
+ const answered = (outcome: RefuelOutcome) => (
+
+
+
+ )
+ const { rerender } = render(answered('none'))
+
+ // Only the first of these is a statement about the road. Telling somebody there is
+ // nothing on a stretch that was never fully checked, or never checked at all, is
+ // worse than saying nothing.
+ expect(screen.getByText(/Nothing found/)).toBeInTheDocument()
+ rerender(answered('incomplete'))
+ expect(screen.getByText(/cut short/)).toBeInTheDocument()
+ rerender(answered('failed'))
+ expect(screen.getByText(/did not answer/)).toBeInTheDocument()
+
+ // And a way to ask again rather than a dead end: the place search is a shared
+ // service that does time out, and no answer with no retry reads as broken.
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-041: the stop stops repeating what the band already says better', () => {
+ // The warning marks the stop somebody finds out at and counts the whole tank; the
+ // band sits where the fuel ends and offers a way out. Both at once prints the answer
+ // above the problem.
+ const warned = (): RoadtripDay => ({
+ ...thirsty(),
+ driveWarnings: [{ index: 2, code: 'range', sinceKm: 640 }],
+ })
+ const { unmount } = wrap(
+ ,
+ )
+ expect(screen.queryByText('640 km')).toBeNull()
+
+ unmount()
+ // With no search to run there is nothing better, so the finding is all there is and
+ // has to stay.
+ wrap()
+ expect(screen.getByText('640 km')).toBeInTheDocument()
+ })
+ })
+
+ /**
+ * Folding a day down to its header.
+ *
+ * The whole header is the control and the hover is the only sign of it: a chevron or a
+ * fold label sitting among the day's own facts reads as a fourth fact about the day
+ * rather than as something to press. Nothing on screen says which way it stands, so the
+ * header has to say it to a screen reader.
+ */
+ describe('a day folded down to its header', () => {
+ it('FE-ROADTRIP-SIDEBAR-042: the header is the control, and it folds by id rather than by number', () => {
+ const onToggleDay = vi.fn()
+ // Id and number deliberately apart. The card is titled by its number and folded by
+ // its id, and on a trip whose days were not created in order those two differ —
+ // mixing them up folds a card the reader did not click.
+ wrap()
+
+ const header = screen.getByRole('button', { name: /Day 1/ })
+ expect(header).toHaveAttribute('aria-expanded', 'true')
+
+ fireEvent.click(header)
+ expect(onToggleDay).toHaveBeenCalledWith(42)
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-043: folded, the stops are gone and the header is what is left', () => {
+ // Folded by id, again with a card whose id is not its number: a set read as numbers
+ // would fold the card titled "Day 42" here, or nothing at all.
+ wrap()
+
+ const folded = screen.getByRole('button', { name: /Day 1/ })
+ expect(folded).toHaveAttribute('aria-expanded', 'false')
+ // The card keeps its head: a day put away is still one the reader scrolls past, and
+ // its own figures are how it is found again.
+ expect(within(folded).getByText('2 stops')).toBeInTheDocument()
+ expect(screen.getAllByText('Hamburg')[0]).not.toBeVisible()
+ // One card at a time. Folding is per day, and the id is what decides which.
+ expect(screen.getByRole('button', { name: /Day 2/ })).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getAllByText('Berlin')[1]).toBeVisible()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-044: the header answers the keys that mean press, and no others', () => {
+ const onToggleDay = vi.fn()
+ wrap()
+ const header = screen.getByRole('button', { name: /Day 1/ })
+
+ // A header wearing a button role gets none of a real button's keyboard behaviour, so
+ // it answers both keys itself. Space has to be swallowed as well as answered, or the
+ // rail scrolls a page down behind the day that was just put away. fireEvent reports
+ // a cancelled event as false.
+ fireEvent.keyDown(header, { key: 'Enter' })
+ expect(fireEvent.keyDown(header, { key: ' ' })).toBe(false)
+ expect(onToggleDay).toHaveBeenCalledTimes(2)
+
+ fireEvent.keyDown(header, { key: 'ArrowDown' })
+ expect(onToggleDay).toHaveBeenCalledTimes(2)
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-045: a rail with nothing to fold offers no control at all', () => {
+ wrap()
+
+ // A viewer gets a heading. A control that does nothing when pressed is worse than
+ // one that is not there.
+ expect(screen.queryByRole('button', { name: /Day 1/ })).toBeNull()
+ expect(screen.getByRole('heading', { level: 3 })).toBeVisible()
+ expect(screen.getByText('Hamburg')).toBeVisible()
+ })
+ })
+
+ /**
+ * How full one stop fills the tank.
+ *
+ * A property of the stop rather than of the traveller: the motorway rapid charger is
+ * worth 80 % because the last fifth costs as long again, the one at the hotel is worth
+ * all of it. The badge is the only way into that figure anywhere in TREK.
+ */
+ describe('the fill badge on a stop that puts fuel back', () => {
+ const refuelling = (fillPercent?: number | null): RoadtripDay => day({
+ stops: [
+ stop({ assignmentId: 1, name: 'Hamburg' }),
+ stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', fillPercent }),
+ ],
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-046: the badge is the way into a figure that is not there yet', () => {
+ const onSelectStop = vi.fn()
+ const onSetStopFill = vi.fn()
+ wrap()
+
+ // The plus stands in the slot the number will occupy: a badge that hid itself until
+ // a figure existed could never be used to make one.
+ fireEvent.click(screen.getByRole('button', { name: '+' }))
+ expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
+ // The whole row is a button too. A click that reached it would select the stop and
+ // move the map out from under the panel that just opened.
+ expect(onSelectStop).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('button', { name: '80' }))
+ expect(onSetStopFill).toHaveBeenCalledWith(20, 80)
+ expect(screen.queryByRole('dialog')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-047: the badge opens from the keyboard and swallows the key', () => {
+ wrap()
+ const badge = screen.getByRole('button', { name: '+' })
+
+ // The same span-with-a-role bargain as the header, one row further in: unanswered,
+ // the only way to the figure is a mouse.
+ fireEvent.keyDown(badge, { key: 'ArrowDown' })
+ expect(screen.queryByRole('dialog')).toBeNull()
+
+ expect(fireEvent.keyDown(badge, { key: 'Enter' })).toBe(false)
+ expect(screen.getByRole('dialog', { name: 'Fills to' })).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-SIDEBAR-048: only a stop that fills up carries the badge, and its own figure reads as one', () => {
+ wrap()
+
+ // An explicit 100 is a decision, not an absent figure: on a trip whose default is 80
+ // it says this one goes right up, and folding it into the invitation would leave the
+ // traveller reading 80 on a stop that fills to 100.
+ expect(screen.getAllByRole('button', { name: '100 %' })).toHaveLength(1)
+ // Hamburg has none at all. A place the trip is for fills no tank, so a fill figure
+ // there is a control for a decision nobody makes.
+ expect(screen.queryByText('+')).toBeNull()
+ })
+ })
})
describe('RoadtripModeSwitch', () => {
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
index 9dd46d130..5850e09b4 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts
@@ -1,6 +1,6 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import type { Assignment, AssignmentsMap, Day } from '../../types'
+import type { Assignment, AssignmentsMap, Day, Settings } from '../../types'
// 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`.
@@ -17,6 +17,8 @@ const { calculateRouteWithLegs, RoutingRefusedError } = vi.hoisted(() => {
vi.mock('../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]
@@ -436,4 +438,231 @@ describe('useRoadtripRoutes', () => {
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): 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 }) => 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])
+ })
+ })
})
diff --git a/client/src/components/Roadtrip/useRoadtripVias.test.ts b/client/src/components/Roadtrip/useRoadtripVias.test.ts
index e32da76ad..b8b605bcd 100644
--- a/client/src/components/Roadtrip/useRoadtripVias.test.ts
+++ b/client/src/components/Roadtrip/useRoadtripVias.test.ts
@@ -11,13 +11,43 @@ const { roadtripApi } = vi.hoisted(() => ({
}))
vi.mock('../../api/client', () => ({ roadtripApi }))
+// Offline is the hook's headline rule and it has no other way in: `editable` is read
+// straight off useNetworkMode, so a test that never drives it can only ever see the
+// online answer.
+const { networkMode } = vi.hoisted(() => ({ networkMode: { offline: false } }))
+vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: () => networkMode }))
+
+// The remote path is driven by hand: the hook hands its handler to addListener, and the
+// cases below call it the way a message from another screen would arrive.
+const { addListener, removeListener } = vi.hoisted(() => ({
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+}))
+vi.mock('../../api/websocket', () => ({
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ getSocketId: vi.fn(() => null),
+ setRefetchCallback: vi.fn(),
+ setPreReconnectHook: vi.fn(),
+ addListener,
+ removeListener,
+}))
+
import { useRoadtripVias } from './useRoadtripVias'
const via = (over: Partial<{ id: number; day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }> = {}) => ({
id: 1, day_id: 1, after_order_index: 0, sequence: 0, lat: 53, lng: 10, ...over,
})
+/** Whatever the hook last gave the socket, which is how a change from another screen gets in. */
+const socketHandler = (): ((event: Record) => void) => {
+ const call = addListener.mock.calls[addListener.mock.calls.length - 1]
+ if (!call) throw new Error('the hook never subscribed to the socket')
+ return call[0]
+}
+
beforeEach(() => {
+ networkMode.offline = false
roadtripApi.listVias.mockReset().mockResolvedValue({ vias: [] })
roadtripApi.addVia.mockReset().mockResolvedValue({ via: via() })
roadtripApi.moveVia.mockReset().mockResolvedValue({ via: via() })
@@ -107,4 +137,141 @@ describe('useRoadtripVias', () => {
expect(roadtripApi.addVia).not.toHaveBeenCalled()
})
+it('FE-ROADTRIP-VIAS-008: a via somebody else moved replaces that day and only that day', async () => {
+ // The screen holds the id the router handed it, a string, and the socket sends the
+ // number the server broadcasts. Compared as they arrive, every remote change is lost.
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
+ const { result } = renderHook(() => useRoadtripVias('7', true))
+ await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
+ const untouched = result.current.byDay[1]
+
+ // dayId travels as the route param it came from, so it is a string on the wire too.
+ act(() => socketHandler()({
+ type: 'roadtripVia:changed',
+ tripId: 7,
+ dayId: '2',
+ vias: [via({ id: 8, day_id: 2, lat: 48 }), via({ id: 9, day_id: 2, lat: 49 })],
+ }))
+
+ // The server sends the day's whole list, so this replaces rather than merges: a merge
+ // keeps drawing the point the other screen has just deleted.
+ expect(result.current.byDay[2].map(v => v.id)).toEqual([8, 9])
+ expect(result.current.byDay[1]).toBe(untouched)
+ })
+
+ it('FE-ROADTRIP-VIAS-009: a change to another trip is not applied to this one', async () => {
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
+ const before = result.current.byDay
+
+ act(() => socketHandler()({
+ type: 'roadtripVia:changed', tripId: 8, dayId: '1', vias: [via({ id: 4, day_id: 1 })],
+ }))
+
+ // The same object, not merely an equal one: this list feeds the routing key, so even
+ // handing back a fresh copy re-routes the trip for an event meant for somebody else.
+ expect(result.current.byDay).toBe(before)
+ })
+
+ it('FE-ROADTRIP-VIAS-010: a day emptied elsewhere leaves the map instead of lingering empty', async () => {
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 }), via({ id: 2, day_id: 2 })] })
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(result.current.byDay[2]).toHaveLength(1))
+
+ act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
+
+ // An empty list left behind still answers yes to a `dayId in byDay` check, which is
+ // how the surfaces ask whether a day is shaped at all.
+ expect(2 in result.current.byDay).toBe(false)
+ expect(result.current.byDay[1]).toHaveLength(1)
+
+ const before = result.current.byDay
+ act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, dayId: '2', vias: [] }))
+ // Nothing left to drop, so nothing new to hand out — a copy here would re-route the
+ // whole trip for an event that changed nothing.
+ expect(result.current.byDay).toBe(before)
+ })
+
+ it('FE-ROADTRIP-VIAS-011: only a track event moves the track, and it clears just its own day', async () => {
+ roadtripApi.listVias.mockResolvedValue({
+ vias: [via({ id: 1, day_id: 1 })],
+ tracks: [{ day_id: 1, place_id: 11, stray_km: 0.4 }, { day_id: 2, place_id: 22, stray_km: null }],
+ })
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(result.current.trackByDay[1]?.place_id).toBe(11))
+
+ // A via event carries no track at all. Letting it reach the track map would drop the
+ // badge for the road the day follows every time anybody nudged a point on it.
+ act(() => socketHandler()({
+ type: 'roadtripVia:changed', tripId: 7, dayId: '1', vias: [via({ id: 3, day_id: 1 })],
+ }))
+ expect(result.current.trackByDay[1]?.place_id).toBe(11)
+
+ act(() => socketHandler()({ type: 'roadtripTrack:changed', tripId: 7, dayId: '1', track: null }))
+ expect(1 in result.current.trackByDay).toBe(false)
+ expect(result.current.trackByDay[2]?.place_id).toBe(22)
+
+ act(() => socketHandler()({
+ type: 'roadtripTrack:changed', tripId: 7, dayId: '2', track: { day_id: 2, place_id: 33, stray_km: 1 },
+ }))
+ expect(result.current.trackByDay[2].place_id).toBe(33)
+ })
+
+ it('FE-ROADTRIP-VIAS-012: the listener follows the trip and goes down with the screen', async () => {
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
+ const { result, rerender, unmount } = renderHook(({ id }: { id: number }) => useRoadtripVias(id, true), {
+ initialProps: { id: 7 },
+ })
+ await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
+ const first = socketHandler()
+
+ rerender({ id: 8 })
+ await waitFor(() => expect(roadtripApi.listVias).toHaveBeenCalledWith(8))
+ const second = socketHandler()
+ expect(second).not.toBe(first)
+ expect(removeListener).toHaveBeenCalledWith(first)
+
+ // The live handler is the one built for the trip on screen. One kept from the previous
+ // trip would keep writing that trip's days into this one.
+ act(() => second({
+ type: 'roadtripVia:changed',
+ tripId: 7,
+ dayId: '1',
+ vias: [via({ id: 4, day_id: 1 }), via({ id: 5, day_id: 1 })],
+ }))
+ expect(result.current.byDay[1]).toHaveLength(1)
+
+ // A handler that outlives its screen writes into a trip nobody is looking at.
+ unmount()
+ expect(removeListener).toHaveBeenCalledWith(second)
+ })
+
+ it('FE-ROADTRIP-VIAS-014: with no network the map is told not to offer a drag', async () => {
+ // A via anchors to a POSITION in the day stop list, not to an id, so a write queued
+ // offline and replayed after somebody else added a stop would re-pin the drive onto
+ // a leg nobody chose, silently and hours later. Until the anchor is an assignment id
+ // the honest answer is to stop offering the gesture rather than to lose the edit one
+ // write at a time.
+ roadtripApi.listVias.mockResolvedValue({ vias: [] })
+ networkMode.offline = true
+
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+
+ expect(result.current.editable).toBe(false)
+ })
+
+ it('FE-ROADTRIP-VIAS-013: an event that names no day is dropped rather than filed under NaN', async () => {
+ roadtripApi.listVias.mockResolvedValue({ vias: [via({ id: 1, day_id: 1 })] })
+ const { result } = renderHook(() => useRoadtripVias(7, true))
+ await waitFor(() => expect(result.current.byDay[1]).toHaveLength(1))
+ const before = result.current.byDay
+
+ act(() => socketHandler()({ type: 'roadtripVia:changed', tripId: 7, vias: [via({ id: 4, day_id: 1 })] }))
+
+ // Unguarded this lands under the key NaN, and everything that walks the days then
+ // routes a day the trip does not have.
+ expect(result.current.byDay).toBe(before)
+ expect(Object.keys(result.current.byDay)).toEqual(['1'])
+ })
})
diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
index 60ba14115..2674d09d2 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
+++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
@@ -1,4 +1,4 @@
-// FE-TP-ROAD-001 to FE-TP-ROAD-056
+// FE-TP-ROAD-001 to FE-TP-ROAD-074
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -15,6 +15,7 @@ import {
addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi,
} from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
+import { dayColor } from '../../components/Roadtrip/dayColors'
/**
* The road trip half of the planner hook.
@@ -89,6 +90,7 @@ const rt = vi.hoisted(() => {
days: [] as Array>,
quietDays: [] as unknown[],
lines: [] as unknown[],
+ lineDays: [] as number[],
accessLines: [] as unknown[],
vias: [] as unknown[],
segments: [] as unknown[],
@@ -271,6 +273,8 @@ beforeEach(() => {
rt.vias.stale = false
rt.vias.editable = true
rt.routes.days = []
+ rt.routes.lines = []
+ rt.routes.lineDays = []
rt.corridor.day = undefined
rt.corridor.visible = []
rt.corridor.widthKm = 5
@@ -1189,3 +1193,346 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => {
expect(result.current.stopDraft).toBeNull()
})
})
+
+/** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */
+const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({
+ placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex,
+})
+
+/** Four drawn legs: two on the first day, one each on the two after it. */
+const LINES: [number, number][][] = [
+ [[53.55, 9.99], [53.2, 10.7]],
+ [[53.2, 10.7], [52.52, 13.4]],
+ [[52.52, 13.4], [50.1, 12.0]],
+ [[50.1, 12.0], [48.13, 11.58]],
+]
+
+/** Per-day colours on or off, without dropping the defaults the providers read. */
+function setDayColors(on: boolean) {
+ useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, roadtrip_day_colors: on } })
+}
+
+describe('useTripPlanner road trip: folding a day off the map', () => {
+ /**
+ * Three cards, and two of the places drawn on two cards each: the stop a day ends on is
+ * the stop the next one sets off from. That overlap is what the second pass below is
+ * about, and a fixture without it would let a one-pass answer through.
+ */
+ const threeCards = () => {
+ seedTrip({
+ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 }), buildDay({ id: 7, day_number: 3 })],
+ places: [
+ buildPlace({ id: 1101, lat: 53.55, lng: 9.99 }),
+ buildPlace({ id: 1102, lat: 52.52, lng: 13.4 }),
+ buildPlace({ id: 1103, lat: 50.1, lng: 12.0 }),
+ buildPlace({ id: 1104, lat: 48.13, lng: 11.58 }),
+ ],
+ })
+ rt.corridor.day = { dayId: 5, dayNumber: 1 }
+ rt.routes.days = [
+ { dayId: 5, dayNumber: 1, stops: [drawn(1101, 53.55, 9.99, 5, 0), drawn(1102, 52.52, 13.4, 5, 1)], geometry: LINES[0] },
+ { dayId: 6, dayNumber: 2, stops: [drawn(1102, 52.52, 13.4, 6, 0), drawn(1103, 50.1, 12.0, 6, 1)], geometry: LINES[2] },
+ { dayId: 7, dayNumber: 3, stops: [drawn(1103, 50.1, 12.0, 7, 0), drawn(1104, 48.13, 11.58, 7, 1)], geometry: LINES[3] },
+ ]
+ rt.routes.lines = LINES
+ rt.routes.lineDays = [1, 1, 2, 3]
+ }
+
+ it('FE-TP-ROAD-060: folding a day folds that one alone, and folding it again brings it back', async () => {
+ // One set for the whole rail, so a header that wrote its own answer over it instead
+ // of editing a copy would fold one card and unfold every other in the same click.
+ threeCards()
+ const { result } = await renderRoadtrip()
+
+ act(() => { result.current.toggleRoadtripDay(6) })
+ act(() => { result.current.toggleRoadtripDay(7) })
+ expect([...result.current.collapsedRoadtripDays]).toEqual([6, 7])
+
+ act(() => { result.current.toggleRoadtripDay(6) })
+ expect([...result.current.collapsedRoadtripDays]).toEqual([7])
+ })
+
+ it('FE-TP-ROAD-061: a folded card takes ITS lines off the map and leaves the rest drawn', async () => {
+ // The fold is keyed by day id and the lines are labelled by day number. They are
+ // different numbers on this trip, as on any trip whose days were not created in
+ // order, so a filter built from the wrong one quietly hides nothing at all.
+ threeCards()
+ const { result } = await renderRoadtrip()
+ expect(result.current.roadtripMapLines).toHaveLength(4)
+
+ act(() => { result.current.toggleRoadtripDay(6) })
+
+ expect(result.current.roadtripMapLines).toEqual([LINES[0], LINES[1], LINES[3]])
+ })
+
+ it('FE-TP-ROAD-062: the colours drop with the lines, so what is left still lines up', async () => {
+ // The map reads the two lists side by side. Leaving a folded day colour in shifts
+ // every colour after it onto the wrong road, which reads as the days having moved.
+ threeCards()
+ const { result } = await renderRoadtrip()
+ // Off is an absent list rather than a list of blues: a trip that never turns this on
+ // is handed nothing and the map paints what it always painted.
+ expect(result.current.roadtripLineColors).toBeUndefined()
+
+ act(() => { setDayColors(true) })
+ act(() => { result.current.toggleRoadtripDay(6) })
+
+ expect(result.current.roadtripLineColors).toEqual([dayColor(1), dayColor(1), dayColor(3)])
+ expect(result.current.roadtripLineColors).toHaveLength(result.current.roadtripMapLines.length)
+ })
+
+ it('FE-TP-ROAD-063: a folded card takes its own stops off the map with it', async () => {
+ threeCards()
+ const { result } = await renderRoadtrip()
+
+ act(() => { result.current.toggleRoadtripDay(5) })
+
+ expect(result.current.roadtripMapPlaces.map(p => p.id)).not.toContain(1101)
+ // The fold and nothing else: the planner own map still holds it, so a filter that
+ // dropped the place for some unrelated reason would show up right here.
+ expect(result.current.mapPlaces.map(p => p.id)).toContain(1101)
+ })
+
+ it('FE-TP-ROAD-064: a stop another card still draws stays, and goes only when that card folds too', async () => {
+ // The second pass is the whole rule. The place a day ends on is the place the next
+ // day sets off from, so folding one card must not rub it off the other.
+ threeCards()
+ const { result } = await renderRoadtrip()
+
+ act(() => { result.current.toggleRoadtripDay(5) })
+ expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1102, 1103, 1104])
+
+ act(() => { result.current.toggleRoadtripDay(6) })
+ expect(result.current.roadtripMapPlaces.map(p => p.id)).toEqual([1103, 1104])
+ })
+})
+
+describe('useTripPlanner road trip: how full one stop fills up', () => {
+ it('FE-TP-ROAD-065: the fill is one field on one place, and handing it back sends null rather than nothing', async () => {
+ // null is not "leave it alone", it is "follow my own setting again" — the only way
+ // back from a figure typed on a single stop. Left out of the payload the update folds
+ // it into "unchanged" and the stop keeps the number for good.
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })], assignments: { '5': [stopAt(11, 5, 0)] } })
+ rt.corridor.day = { dayId: 5, dayNumber: 1 }
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { await result.current.setRoadtripStopFill(1011, 80) })
+ expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { fill_percent: 80 })
+
+ actions.updatePlace.mockRejectedValue(new Error('read only'))
+ await act(async () => { await result.current.setRoadtripStopFill(1011, null) })
+ expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 1011, { fill_percent: null })
+ expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true)
+ })
+})
+
+describe('useTripPlanner road trip: a card that opens with yesterday stop', () => {
+ /**
+ * One card, day 6, whose drive begins with a stop stored on day 5: the night drive
+ * arrived after midnight, so the rail draws that stop here while the server still files
+ * it under the day it set off from.
+ */
+ const spilledCard = () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
+ rt.corridor.day = { dayId: 6, dayNumber: 2 }
+ rt.routes.days = [{
+ dayId: 6,
+ dayNumber: 2,
+ stops: [drawn(1102, 53.0, 11.5, 5, 1), drawn(1103, 52.52, 13.4, 6, 0)],
+ geometry: [[53.55, 9.99], [53.0, 11.5], [52.8, 12.4], [52.52, 13.4]],
+ }]
+ }
+
+ it('FE-TP-ROAD-066: a via is filed under the day its anchor stop is STORED on, not the card it is drawn on', async () => {
+ // Written with the card own numbers the via matches no stop the next time the day is
+ // routed: the road springs back to where it was and the gesture reads as having done
+ // nothing at all.
+ spilledCard()
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { await result.current.addRoadtripVia(52.8, 12.4) })
+
+ expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 52.8, 12.4)
+ })
+
+ it('FE-TP-ROAD-072: a via dropped on the incoming night drive follows the stop it left from', async () => {
+ // The stretch between the card first drawn point and its first stop is last night
+ // driving: it is drawn here, but it leaves from a stop on the card before this one.
+ // Anchored to this card first stop instead, the via would be filed on the leg AFTER
+ // that stop, and the route would run forward, double back to the point and carry on.
+ //
+ // The rule used to be asked of the insert index, which cannot answer it:
+ // insertIndexForAlong clamps to at least 1 for any card with two stops or more, and
+ // the rail publishes no other kind. The guard read correctly and never ran.
+ spilledCard()
+ rt.routes.days[0].spills = [{
+ at: 0,
+ count: 1,
+ fromDayNumber: 1,
+ // The stop the night drive left from: FIRST on day 5, while the first stop drawn
+ // on this card is the second one, reached after midnight. Different numbers on
+ // purpose, or the two paths through the lookup would answer alike and this case
+ // would pass whichever one ran.
+ fromStop: drawn(1101, 53.55, 9.99, 5, 0),
+ }]
+ const { result } = await renderRoadtrip()
+
+ // Half way along the night stretch, well before the first stop drawn on this card.
+ await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
+
+ expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.3, 10.7)
+ })
+
+ it('FE-TP-ROAD-073: a point before the first stop of an ordinary card still takes that stop', async () => {
+ // The other half of the same rule, and the reason it is asked of the spill rather
+ // than of the distance alone: a card that received no night drive has nothing before
+ // its first stop but its own first leg, so a point there belongs on that leg.
+ spilledCard()
+ rt.routes.days[0].spills = []
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { await result.current.addRoadtripVia(53.3, 10.7) })
+
+ expect(rt.vias.add).toHaveBeenCalledWith(5, 1, 53.3, 10.7)
+ })
+
+ it('FE-TP-ROAD-067: a drag keeps to the day the via is stored on, wherever that day stops are drawn', async () => {
+ // Not "the card with that id". After a night drive the stops of day 5 are drawn on
+ // card 6, and measuring the drag against card 5 alone leaves it with no anchor to
+ // offer — which is exactly the anchor it needed.
+ spilledCard()
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { await result.current.moveRoadtripVia(5, 9, 52.8, 12.4) })
+ expect(rt.vias.move).toHaveBeenCalledWith(5, 9, 52.8, 12.4, 1)
+
+ // The same rule the other way round: the stop nearest that point belongs to day 5, so
+ // a via of day 6 takes no anchor from it and the pin it already has stands.
+ await act(async () => { await result.current.moveRoadtripVia(6, 9, 52.8, 12.4) })
+ expect(rt.vias.move).toHaveBeenLastCalledWith(6, 9, 52.8, 12.4, undefined)
+ })
+})
+
+describe('useTripPlanner road trip: somewhere to fill up', () => {
+ /**
+ * A day driven straight down the tenth meridian, so a kilometre along the road is a
+ * kilometre anybody can check: a degree of latitude is 111 km and nothing here bends.
+ * Its first two stops are stored on the day before, the night drive having arrived
+ * after midnight, which is what the accepting case turns on.
+ */
+ const drivenCard = () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
+ rt.corridor.day = { dayId: 6, dayNumber: 2 }
+ rt.routes.days = [{
+ dayId: 6,
+ dayNumber: 2,
+ stops: [drawn(1201, 53, 10, 5, 1), drawn(1202, 52, 10, 5, 2), drawn(1203, 51, 10, 6, 0)],
+ geometry: [[53, 10], [52, 10], [51, 10]],
+ drivingGeometry: [[53, 10], [52, 10], [51, 10]],
+ }]
+ }
+
+ /** Where the tank runs out: 190 km into a 222 km day, on the second leg. */
+ const dry = { legIndex: 1, intoLegKm: 79, drivenMeters: 190_000, sinceKm: 500, lat: 51.29, lng: 10 }
+
+ const pump = (name: string, lat: number) => ({
+ osm_id: `node/${name}`, name, lat, lng: 10, category: 'fuel', poi_type: 'fuel',
+ address: null, website: null, phone: null, opening_hours: null, cuisine: null,
+ })
+
+ it('FE-TP-ROAD-068: the day own stops go with the question, so a pump already on the plan is not offered again', async () => {
+ drivenCard()
+ const pois = vi.spyOn(mapsApi, 'pois').mockResolvedValue({
+ pois: [pump('Rasthof Dammer Berge', 52.5), pump('Schon geplant', 52)],
+ source: 'openstreetmap',
+ truncated: false,
+ } as never)
+ const { result } = await renderRoadtrip()
+
+ // A day the rail draws no drive for has no line to measure along, and asking anyway
+ // spends a request on a question that cannot be answered.
+ act(() => { result.current.askRefuel(999, dry) })
+ expect(pois).not.toHaveBeenCalled()
+
+ await act(async () => { result.current.askRefuel(6, dry) })
+ await waitFor(() => expect(result.current.refuel.outcome).toBe('found'))
+
+ expect(pois).toHaveBeenCalledTimes(1)
+ // The second one stands on the day own middle stop, and both are within range of the
+ // dry point — so only the day stops travelling with the question keep it out.
+ expect(result.current.refuel.results.map(r => r.name)).toEqual(['Rasthof Dammer Berge'])
+ // Which dry point is being answered, so a second warning cannot read the first list.
+ expect(result.current.refuel.openFor).toBe('6:1')
+ })
+
+ it('FE-TP-ROAD-069: while offers are on the table the map frames THEM, not the alternatives under them', async () => {
+ // Somebody is being asked to accept a stop, and a stop off the edge of the map cannot
+ // be judged. Averaging the two frames would have shown neither properly.
+ drivenCard()
+ rt.alt.open = {
+ dayId: 6, index: 0, loading: false, error: false,
+ // Two of them, because one road is not a choice and the picker draws nothing for it.
+ routes: [
+ { coordinates: [[53, 10], [51, 10]], distance: 222_000, duration: 8_400, divergence: { lat: 52, lng: 10 } },
+ { coordinates: [[53, 10], [52.4, 10.6], [51, 10]], distance: 240_000, duration: 9_000, divergence: { lat: 52.4, lng: 10.6 } },
+ ],
+ }
+ vi.spyOn(mapsApi, 'pois').mockResolvedValue({
+ pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
+ } as never)
+ const { result } = await renderRoadtrip()
+ expect(result.current.mapFocusPoints).toEqual(result.current.alternativeFocusPoints)
+ expect(result.current.mapFocusPoints.length).toBeGreaterThan(0)
+
+ await act(async () => { result.current.askRefuel(6, dry) })
+ await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
+
+ expect(result.current.mapFocusPoints).toEqual([[52.5, 10]])
+ })
+
+ it('FE-TP-ROAD-070: accepting one closes the offers and opens the popup at the stop OWN day and position', async () => {
+ // The index the offer was measured at counts along the card, and this card begins
+ // with two stops stored on the day before. Written with the card numbers the new stop
+ // lands in the wrong day list, at a position that means something else there.
+ drivenCard()
+ vi.spyOn(mapsApi, 'pois').mockResolvedValue({
+ pois: [pump('Rasthof Dammer Berge', 52.5)], source: 'openstreetmap', truncated: false,
+ } as never)
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { result.current.askRefuel(6, dry) })
+ await waitFor(() => expect(result.current.refuel.offered).toHaveLength(1))
+
+ // Dry on the first leg, so the stop goes in front of the one it was measured against:
+ // the card second, which day 5 holds at position 2.
+ act(() => { result.current.acceptRefuel(6, result.current.refuel.offered[0], { ...dry, legIndex: 0 }) })
+
+ expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 2 })
+ expect(result.current.refuel.openFor).toBeNull()
+ })
+
+ it('FE-TP-ROAD-074: a reader changing a stop from the rail writes nothing at all', async () => {
+ // The two one-field writes the rail offers used to go straight to the API while their
+ // neighbour setRoadtripStay checked first. The server refused them, so nothing was
+ // ever saved, but the reader got an error toast for touching a control that should
+ // not have acted — and the rail redrew off a store the write never reached.
+ asReader('place_edit')
+ drivenCard()
+ const { result } = await renderRoadtrip()
+
+ await act(async () => { await result.current.setRoadtripStopKind(1102, 'fuel') })
+ await act(async () => { await result.current.setRoadtripStopFill(1102, 80) })
+
+ expect(actions.updatePlace).not.toHaveBeenCalled()
+ })
+
+ it('FE-TP-ROAD-071: a reader is not handed the stop to accept', async () => {
+ asReader('day_edit')
+ drivenCard()
+ const { result } = await renderRoadtrip()
+
+ act(() => { result.current.acceptRefuel(6, pump('Rasthof Dammer Berge', 52.5) as never, dry) })
+
+ expect(result.current.stopDraft).toBeNull()
+ })
+})
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 387eeb3f9..9aa3dcfbb 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -957,12 +957,13 @@ export function useTripPlanner() {
* field on one place, and the rail redraws itself off the store the moment it lands.
*/
const setRoadtripStopKind = useCallback(async (placeId: number, kind: RoadtripStopType | null) => {
+ if (!can('place_edit', trip)) return
try {
await tripActions.updatePlace(tripId, placeId, { stop_type: kind })
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
- }, [tripId, tripActions, toast, t])
+ }, [tripId, tripActions, toast, t, can, trip])
/**
* How full THIS stop fills the tank, from the road trip rail.
@@ -973,12 +974,13 @@ export function useTripPlanner() {
* until somebody has an opinion about one.
*/
const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => {
+ if (!can('place_edit', trip)) return
try {
await tripActions.updatePlace(tripId, placeId, { fill_percent: percent })
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
- }, [tripId, tripActions, toast, t])
+ }, [tripId, tripActions, toast, t, can, trip])
/**
* Moves a stop within its day, from the road trip rail.
@@ -1286,20 +1288,25 @@ export function useTripPlanner() {
if (best && hit.offRouteKm >= best.offRouteKm) continue
// Which stop the via follows: the last one the car passes before reaching it.
const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0)
- const before = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// Before the card's first stop means the incoming night drive, which is drawn here
// but leaves from a stop on the card BEFORE this one (`nightSpill.ts`). Anchoring
// it to this card's first stop would file the via on the leg AFTER that stop, and
// the route would run forward, double back to the point, and carry on.
- if (before < 0) {
- const from = day.spills?.find(sp => sp.at === 0)?.fromStop
- if (!from) continue
- const owner = from.ownerDayId ?? day.dayId
+ //
+ // Asked of the distance rather than of the index, because the index cannot answer
+ // it: `insertIndexForAlong` clamps to at least 1 for any list of two or more, and
+ // the rail only ever publishes cards with two stops or more. Written against the
+ // index this read as a guard and behaved as dead code, so a via dropped on the
+ // night stretch went to the first drawn stop after all, which is the exact failure
+ // the paragraph above describes.
+ const spilledIn = day.spills?.find(sp => sp.at === 0)?.fromStop
+ if (spilledIn && hit.alongKm < (stopsAlong[0] ?? 0)) {
+ const owner = spilledIn.ownerDayId ?? day.dayId
if (onlyDayId !== undefined && owner !== onlyDayId) continue
- best = { dayId: owner, afterIndex: from.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
+ best = { dayId: owner, afterIndex: spilledIn.ownerIndex ?? 0, offRouteKm: hit.offRouteKm }
continue
}
- const at = before
+ const at = insertIndexForAlong(stopsAlong, hit.alongKm) - 1
// Named by the day the anchor stop is STORED on and its position there, not by the
// card and the position within it. A card is a date and can hold stops from the day
// before (`nightSpill.ts`), so those two numbers differ on any day that received a