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