test(roadtrip): cover the fold, the seam and the live via, and fix what that found

Cases appended to four existing suites for the lines this branch left unrun: the
fold state and the map places it hides, the owner-based via anchor, the day seam
and the proportional geometry slicing, and the live roadtripVia:changed events.
With the four new suites in the commits before this one, coverage of the lines
this branch adds goes from 69 % to 96 %, measured the way the gate measures it —
the routed lines against the diff, not a file average.

Every added case was mutation-checked: the rule it claims to pin was broken in
the source and the case had to fail. Two of them found real defects rather than
missing coverage, and both are fixed here.

A via dropped on the incoming night drive went to the wrong leg. The guard for
it asked the insert index whether the point lay before the card's first stop,
and that index cannot answer: insertIndexForAlong clamps to at least 1 for any
card with two stops or more, and the rail publishes no other kind. So the branch
read correctly and never ran, and a via on the night stretch was filed after the
first drawn stop — the route ran forward, doubled back to the point and carried
on, which is exactly what the comment above it said must not happen. Asked of
the distance instead.

The two one-field writes the rail offers, the stop kind and the fill level, went
straight to the API while their neighbour checked place_edit first. The server
refused them, so nothing was ever saved, but a reader got an error toast for
touching a control that should not have acted.
This commit is contained in:
Maurice
2026-09-10 20:23:56 +02:00
committed by Maurice
parent a9907e2f10
commit 9ce8b7ea9e
5 changed files with 1134 additions and 11 deletions
@@ -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(<TranslationProvider>{ui}</TranslationProvider>)
@@ -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> = {}): 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> = {}): 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(
<RoadtripSidebar routes={routes({ days: [thirsty()], totalStops: 3 })} refuel={search()} />,
)
// 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(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
// 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(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search({ openFor: '1:0', loading: true })}
onAskRefuel={onAskRefuel}
/>,
)
// 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(
<RoadtripSidebar
routes={routes({ days: [card] })}
refuel={search()}
onAskRefuel={onAskRefuel}
/>,
)
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(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', loading: true, close })}
/>,
)
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(
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome: 'found', results: [shell] })}
onAcceptRefuel={onAcceptRefuel}
/>,
)
// 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) => (
<TranslationProvider>
<RoadtripSidebar
routes={routes({ days: [thirsty()], totalStops: 3 })}
refuel={search({ openFor: '1:1', outcome })}
/>
</TranslationProvider>
)
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(
<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} refuel={search()} />,
)
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(<RoadtripSidebar routes={routes({ days: [warned()], totalStops: 3 })} />)
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(<RoadtripSidebar routes={routes({ days: [day({ dayId: 42 })] })} onToggleDay={onToggleDay} />)
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(<RoadtripSidebar
routes={routes({ days: [day({ dayId: 42 }), day({ dayId: 2, dayNumber: 2 })], totalStops: 4 })}
onToggleDay={vi.fn()}
collapsedDayIds={new Set([42])}
/>)
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(<RoadtripSidebar routes={routes()} onToggleDay={onToggleDay} />)
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(<RoadtripSidebar routes={routes()} />)
// 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(<RoadtripSidebar
routes={routes({ days: [refuelling()] })}
onSelectStop={onSelectStop}
onSetStopFill={onSetStopFill}
/>)
// 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(<RoadtripSidebar routes={routes({ days: [refuelling()] })} onSetStopFill={vi.fn()} />)
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(<RoadtripSidebar routes={routes({ days: [refuelling(100)] })} onSetStopFill={vi.fn()} />)
// 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', () => {
@@ -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<Settings>): void => {
act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, ...over } })) })
}
/** Two ordinary days, with the road from Lueneburg to Berlin left over between them. */
const twoDays = () => ({
days: [day(1, 1), day(2, 2)],
assignments: {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
} as AssignmentsMap,
})
/** Every request for one particular road, found by the two ends it was asked for. */
const askedFor = (from: [number, number], to: [number, number]) =>
calculateRouteWithLegs.mock.calls.filter(c =>
c[0][0].lat === from[0] && c[0][c[0].length - 1].lat === to[0])
/** A run whose legs are a round 100 km each, so the range budget reads off the page. */
const hundreds = (legs: number, coordinates: [number, number][]) => ({
coordinates,
distance: legs * 100000,
duration: legs * 3600,
legs: Array.from({ length: legs }, () => ({ distance: 100000, duration: 3600, text: '100 km' })),
})
it('FE-ROADTRIP-ROUTES-021: off, the join is neither driven nor counted; on, it is both', async () => {
const { days: daysList, assignments } = twoDays()
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.loading).toBe(false))
// A gap the day-at-a-time plan simply has, and the totals say so by leaving it out.
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(0)
expect(result.current.lines).toHaveLength(2)
expect(result.current.totalDistance).toBe(200000)
setting({ roadtrip_connect_days: true })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
await waitFor(() => expect(result.current.lines).toHaveLength(3))
expect(result.current.totalDistance).toBe(300000)
})
it('FE-ROADTRIP-ROUTES-022: the joining stroke wears the day it leaves, its kilometres the day it reaches', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.lines).toHaveLength(3))
// Same length and same order as `lines`, or a colour lands on the wrong day.
expect(result.current.lineDays).toHaveLength(result.current.lines.length)
// The stroke starts at a stop on day 1, so drawing it as day 2 would make day 2
// look like it begins somewhere it has no stop.
expect(result.current.lineDays).toEqual([1, 1, 2])
// The kilometres go the other way: they are driven on the day they arrive.
expect(result.current.days.map(d => d.distance)).toEqual([100000, 200000])
})
it('FE-ROADTRIP-ROUTES-023: a night drive is drawn on the card it reaches only once the switch is on', async () => {
// Stored inside one day and real driving, but on screen it runs from a stop on one
// card to a stop on the next, and a line between two cards is what the switch asks
// about. Its kilometres belong to the day it lands on either way.
const daysList = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '21:00', dwell: 90 }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: HAMBURG }]),
} as AssignmentsMap
// Three hours a leg, which is what puts the second stop past midnight.
calculateRouteWithLegs.mockResolvedValue({
...routed(1), duration: 10800, legs: [{ distance: 100000, duration: 10800, text: '100 km' }],
})
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.lines).toHaveLength(2))
expect(result.current.days[0].distance).toBe(200000)
setting({ roadtrip_connect_days: true })
expect(result.current.lines).toHaveLength(3)
expect(result.current.lineDays).toEqual([1, 2, 2])
expect(result.current.days[0].distance).toBe(300000)
})
it('FE-ROADTRIP-ROUTES-024: vias on the join reach the router in sequence order, and come back as one drive', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
// Filed after the last stop of day 1, which is the join itself. Handed over in the
// wrong order on purpose: driven through its points backwards the seam doubles back.
const vias = {
1: [
{ id: 2, day_id: 1, after_order_index: 1, sequence: 1, lat: 53.0, lng: 11.5 },
{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 },
],
}
// A shaped seam comes back as one leg per waypoint pair.
const inPieces = {
...routed(1),
legs: [
{ distance: 30000, duration: 1000, text: '30 km' },
{ distance: 30000, duration: 1000, text: '30 km' },
{ distance: 40000, duration: 1600, text: '40 km' },
],
}
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
Promise.resolve(wp.length === 4 ? inPieces : routed(1)))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments, 'driving', vias))
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
const [waypoints] = askedFor(LUENEBURG, BERLIN)[0]
expect(waypoints.map((w: { lat: number }) => w.lat)).toEqual([LUENEBURG[0], 53.2, 53.0, BERLIN[0]])
// Three pieces, one drive: the rail carries the whole road across the join rather
// than the first stretch of it.
await waitFor(() => expect(result.current.days[1].distance).toBe(200000))
})
it('FE-ROADTRIP-ROUTES-025: dragging a via on the join asks for that road again', async () => {
// The first answer is filed under the pair of stops. Skipping the seam whenever any
// answer existed is what made a via on the join do visibly nothing at all.
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
const vias = { 1: [{ id: 1, day_id: 1, after_order_index: 1, sequence: 0, lat: 53.2, lng: 10.9 }] }
const { rerender } = renderHook(
({ v }: { v: Record<number, typeof vias[1]> }) => useRoadtripRoutes(7, daysList, assignments, 'driving', v),
{ initialProps: { v: vias } },
)
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
rerender({ v: { 1: [{ ...vias[1][0], lat: 53.9, lng: 10.4 }] } })
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(2))
const [waypoints] = askedFor(LUENEBURG, BERLIN)[1]
expect(waypoints[1]).toEqual({ lat: 53.9, lng: 10.4 })
})
it('FE-ROADTRIP-ROUTES-026: a join that will not route leaves both cards their own driving', async () => {
setting({ roadtrip_connect_days: true })
const { days: daysList, assignments } = twoDays()
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) =>
wp[0].lat === LUENEBURG[0] ? Promise.reject(new Error('503')) : Promise.resolve(routed(1)))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1))
await waitFor(() => expect(result.current.loading).toBe(false))
// Let the refusal settle: what it must not do is file a leg anyway.
await act(async () => { await Promise.resolve() })
expect(result.current.lines).toHaveLength(2)
expect(result.current.lineDays).toEqual([1, 2])
// Partial rather than invented, exactly like any other leg that will not route.
expect(result.current.totalDistance).toBe(200000)
})
it('FE-ROADTRIP-ROUTES-027: the road across the join is spent from the tank before the next card fills up', async () => {
// A tank does not empty overnight and the join is real driving, so the day it
// reaches starts with those kilometres already on the clock — which moves the point
// the fuel runs out a whole leg earlier.
const SOUTH: [number, number] = [51.5, 13.5]
const FURTHER_SOUTH: [number, number] = [51.05, 13.74]
setting({ roadtrip_range_km: 250 })
const daysList = [day(1, 1), day(2, 2)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '10:00' }, { id: 4, at: SOUTH }, { id: 5, at: FURTHER_SOUTH }]),
} as AssignmentsMap
calculateRouteWithLegs.mockImplementation((wp: { lat: number }[]) => Promise.resolve(
wp[0].lat === BERLIN[0]
? hundreds(2, [BERLIN, SOUTH, FURTHER_SOUTH])
: hundreds(1, wp[0].lat === LUENEBURG[0] ? [LUENEBURG, BERLIN] : [HAMBURG, LUENEBURG])))
const { result } = renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => expect(result.current.days).toHaveLength(2))
await waitFor(() => expect(result.current.days[1].dryPoints).toHaveLength(1))
// 100 km carried over from day 1, so the 250th is reached halfway down the second leg.
expect(result.current.days[1].dryPoints![0]).toMatchObject({ legIndex: 1, drivenMeters: 150000 })
setting({ roadtrip_connect_days: true })
await waitFor(() => expect(result.current.days[1].distance).toBe(300000))
const [dry] = result.current.days[1].dryPoints!
expect(dry).toMatchObject({ legIndex: 0, drivenMeters: 50000 })
// Walked along the roads this day drives, not along the drawn line: 50 km down that
// one is still on the join, north of Berlin, a whole leg from where the tank empties.
expect(dry.lat).toBeLessThan(BERLIN[0])
})
})
})
@@ -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<string, unknown>) => 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'])
})
})
@@ -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<Record<string, unknown>>,
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()
})
})
+16 -9
View File
@@ -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