From d3aad4c41e1ba20922b8d8e063ca055a82a70ad4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 7 Sep 2026 18:10:24 +0200 Subject: [PATCH] test(roadtrip): cover the planner half the mode switch unlocks The road trip handlers in useTripPlanner are unreachable until the addon is on for the instance and the mode is on for the trip, so the existing suite never entered them: 360 of the file's new lines and branches had never run. Turns both switches on and drives what the rail, the corridor panel and the map call. The cases that matter are the ones where a via has to be re-anchored: a via records which stop it follows by position, so a reorder, a move between days or an insert that forgets the correction silently re-shapes the drive. --- .../useTripPlanner.roadtrip.test.tsx | 1079 +++++++++++++++++ 1 file changed, 1079 insertions(+) create mode 100644 client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx new file mode 100644 index 000000000..9efa9cb9b --- /dev/null +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -0,0 +1,1079 @@ +// FE-TP-ROAD-001 to FE-TP-ROAD-056 +import React from 'react' +import { renderHook, act, waitFor } from '@testing-library/react' +import { TranslationProvider } from '../../i18n/TranslationContext' +import { useTripPlanner } from './useTripPlanner' +import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { useAuthStore } from '../../store/authStore' +import { usePluginStore } from '../../store/pluginStore' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSettingsStore } from '../../store/settingsStore' +import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../../tests/helpers/factories' +import { + addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi, +} from '../../api/client' +import { accommodationRepo } from '../../repo/accommodationRepo' + +/** + * The road trip half of the planner hook. + * + * Everything here is unreachable until two things are true at once: the addon is on for + * the instance AND the mode is on for this trip. The sibling suite runs with both off, + * which is the ordinary planner; this one turns them on and drives the handlers the rail, + * the corridor panel and the map call. + * + * The five road trip hooks are replaced by fixtures. They have their own suites, and the + * point here is what the planner DOES with what they report: which day a hit lands on, + * which position in the chain, and, in every case that moves a stop, the correction that + * keeps the vias pinned to the legs the traveller drew them for. A via anchors by + * POSITION, so a reorder that forgets to re-anchor silently re-shapes the drive. + */ + +// ── Router ──────────────────────────────────────────────────────────────────── +const navigate = vi.fn() +let routeParams: { id?: string } = { id: '42' } +let searchParams = new URLSearchParams() + +vi.mock('react-router', () => ({ + useParams: () => routeParams, + useNavigate: () => navigate, + useSearchParams: () => [searchParams, vi.fn()], +})) + +vi.mock('../../hooks/useTripWebSocket', () => ({ useTripWebSocket: vi.fn() })) + +const updateRouteForDay = vi.fn(async (_dayId: number | null) => {}) +vi.mock('../../hooks/useRouteCalculation', () => ({ + useRouteCalculation: () => ({ + route: null, + routeSegments: [], + routeVias: [], + routeInfo: null, + setRoute: vi.fn(), + setRouteInfo: vi.fn(), + updateRouteForDay, + }), +})) + +vi.mock('../../hooks/useAirtrailConnection', () => ({ + useAirtrailConnection: () => ({ airtrailEnabled: false, connected: false, available: false, loading: false }), +})) + +vi.mock('../../repo/accommodationRepo', () => ({ + accommodationRepo: { list: vi.fn(async () => ({ accommodations: [] })) }, +})) + +vi.mock('../../services/photoService', () => ({ + getCached: vi.fn(() => undefined), + fetchPhoto: vi.fn(), +})) + +// ── The road trip fixtures ──────────────────────────────────────────────────── +// One object per hook, kept identity-stable across renders so the hook's own +// memoisation behaves the way it does in the app. Set the fields BEFORE mounting. +const rt = vi.hoisted(() => { + const vias = { + byDay: {} as Record>, + trackByDay: {} as Record, + stale: false, + editable: true, + add: vi.fn(async () => {}), + addMany: vi.fn(async () => {}), + move: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + reanchor: vi.fn(async () => {}), + } + const routes = { + days: [] as Array>, + quietDays: [] as unknown[], + lines: [] as unknown[], + accessLines: [] as unknown[], + vias: [] as unknown[], + segments: [] as unknown[], + totalDistance: 0, + totalDuration: 0, + totalStops: 0, + loading: false, + } + const corridor = { + dayId: '', + setDayId: vi.fn(), + day: undefined as Record | undefined, + categories: ['fuel'], + toggleCategory: vi.fn(), + widthKm: 5, + setWidthKm: vi.fn(), + search: { + results: [] as unknown[], + progress: { done: 0, total: 0 }, + loading: false, + capped: false, + failedAreas: 0, + truncatedAreas: 0, + error: false, + spine: [] as Array<{ lat: number; lng: number }>, + search: vi.fn(), + clear: vi.fn(), + }, + nameFilter: '', + setNameFilter: vi.fn(), + anchors: [] as unknown[], + section: null as unknown, + setSection: vi.fn(), + sectionKm: 25, + setSectionKm: vi.fn(), + socketFilter: '', + setSocketFilter: vi.fn(), + minKw: 0, + setMinKw: vi.fn(), + visible: [] as Array>, + insertIndexFor: vi.fn(() => 1), + stopsAlongKm: [] as number[], + } + const alt = { + open: null as null | Record, + ask: vi.fn(), + close: vi.fn(), + } + return { vias, routes, corridor, alt } +}) + +vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({ useRoadtripVias: () => rt.vias })) +vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({ useRoadtripRoutes: () => rt.routes })) +vi.mock('../../components/Roadtrip/useRoadtripCorridor', () => ({ useRoadtripCorridor: () => rt.corridor })) +vi.mock('../../components/Roadtrip/useRouteAlternatives', () => ({ useRouteAlternatives: () => rt.alt })) +vi.mock('../../components/Roadtrip/useFollowTrack', () => ({ + useFollowTrack: () => ({ busy: false, dayId: null, run: vi.fn(), attach: vi.fn(), detach: vi.fn() }), +})) + +// ── Store fixtures ──────────────────────────────────────────────────────────── +const toasts: Array<{ message: string; type: string }> = [] + +function makeActions() { + return { + loadTrip: vi.fn(async () => undefined), + loadReservations: vi.fn(async () => undefined), + loadBudgetItems: vi.fn(async () => undefined), + loadFiles: vi.fn(async () => undefined), + refreshDays: vi.fn(async () => undefined), + addPlace: vi.fn(async () => ({ id: 900, name: 'Rasthof' })), + updatePlace: vi.fn(async () => undefined), + deletePlace: vi.fn(async () => undefined), + deletePlacesMany: vi.fn(async () => undefined), + updatePlacesMany: vi.fn(async () => undefined), + addFile: vi.fn(async () => undefined), + assignPlaceToDay: vi.fn(async () => ({ id: 555 })), + moveAssignment: vi.fn(async () => undefined), + removeAssignment: vi.fn(async () => undefined), + reorderAssignments: vi.fn(async () => undefined), + reorderDays: vi.fn(async () => undefined), + insertDay: vi.fn(async () => undefined), + updateDayTitle: vi.fn(async () => undefined), + addReservation: vi.fn(async () => ({ id: 77 })), + updateReservation: vi.fn(async () => ({ id: 77 })), + deleteReservation: vi.fn(async () => undefined), + setSelectedDay: vi.fn(() => undefined), + } +} +let actions: ReturnType + +function wrapper({ children }: { children: React.ReactNode }) { + return {children} +} + +/** Mount with the addon on and the mode on, and wait until both have landed. */ +async function renderRoadtrip() { + const rendered = renderHook(() => useTripPlanner(), { wrapper }) + await act(async () => { await Promise.resolve() }) + await waitFor(() => expect(rendered.result.current.roadtripActive).toBe(true)) + return rendered +} + +function seedTrip(extra: Partial = {}) { + const trip = buildTrip({ id: 42, title: 'Nordkap' }) + useTripStore.setState({ + trip, + isLoading: false, + days: [], + places: [], + assignments: {}, + reservations: [], + ...(actions as unknown as Partial), + ...extra, + } as Partial) + return trip +} + +/** + * Demote the current session to somebody who may look but not touch. + * + * An instance admin passes every check by role, so the level alone proves nothing: + * the user has to be an ordinary one as well. + */ +function asReader(actionKey: string) { + seedStore(useAuthStore, { user: buildUser({ id: 2, role: 'user' }), isAuthenticated: true }) + usePermissionsStore.setState({ permissions: { [actionKey]: 'admin' } }) +} + +/** A stop as the rail counts them: an assignment whose place has coordinates. */ +function stopAt(id: number, dayId: number, orderIndex: number, over: Record = {}) { + return buildAssignment({ + id, + day_id: dayId, + order_index: orderIndex, + place: buildPlace({ id: 1000 + id, lat: 53 + orderIndex, lng: 10 + orderIndex }), + ...over, + }) +} + +/** A row the rail never shows, because the map cannot put it anywhere. */ +function placeless(id: number, dayId: number, orderIndex: number) { + return buildAssignment({ + id, + day_id: dayId, + order_index: orderIndex, + place: buildPlace({ id: 2000 + id, lat: null as never, lng: null as never }), + }) +} + +const poi = (over: Record = {}) => ({ + osm_id: 'node/1', + name: 'Rasthof Dammer Berge', + lat: 52.5, + lng: 8.2, + category: 'fuel', + poi_type: 'fuel', + address: 'A1', + website: null, + phone: null, + opening_hours: null, + cuisine: null, + source: 'openstreetmap' as const, + offRouteKm: 0.4, + alongKm: 120, + ...over, +}) + +const via = (id: number, dayId: number, afterOrderIndex: number, sequence = 0, lat = 53.2, lng = 10.4) => + ({ id, day_id: dayId, after_order_index: afterOrderIndex, sequence, lat, lng }) + +beforeEach(() => { + vi.clearAllMocks() + resetAllStores() + routeParams = { id: '42' } + searchParams = new URLSearchParams() + toasts.length = 0 + actions = makeActions() + + rt.vias.byDay = {} + rt.vias.stale = false + rt.vias.editable = true + rt.routes.days = [] + rt.corridor.day = undefined + rt.corridor.visible = [] + rt.corridor.widthKm = 5 + rt.corridor.stopsAlongKm = [] + rt.corridor.search.spine = [] + rt.corridor.insertIndexFor.mockReturnValue(1) + rt.alt.open = null + + usePluginStore.setState({ plugins: [], loaded: true }) + useBackgroundTasksStore.setState({ tasks: [] }) + window.__addToast = ((message: string, type: string) => { + toasts.push({ message, type }) + return 1 + }) as unknown as typeof window.__addToast + seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true, placesPhotosEnabled: false }) + + // The two switches this whole file depends on. + sessionStorage.setItem('trip-roadtrip-42', '1') + vi.spyOn(addonsApi, 'enabled').mockResolvedValue({ addons: [{ id: 'roadtrip' }] } as never) + + vi.spyOn(authApi, 'getAppConfig').mockResolvedValue({}) + vi.spyOn(healthApi, 'features').mockResolvedValue({ bookingImport: false, aiParsing: false }) + vi.spyOn(tripsApi, 'getMembers').mockResolvedValue({ owner: null, members: [] }) + vi.spyOn(accommodationsApi, 'list').mockResolvedValue({ accommodations: [] }) + vi.spyOn(accommodationsApi, 'create').mockResolvedValue({ id: 7 } as never) + vi.spyOn(airtrailApi, 'sync').mockResolvedValue({ changed: 0 }) + vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: '' } as never) + vi.mocked(accommodationRepo.list).mockResolvedValue({ accommodations: [] }) +}) + +afterEach(() => { + delete window.__addToast + sessionStorage.clear() + vi.restoreAllMocks() +}) + +describe('useTripPlanner road trip: a hit on the drive', () => { + it('FE-TP-ROAD-001: a corridor hit opens the popup on the day being searched, at its place in the chain', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.insertIndexFor.mockReturnValue(2) + + const { result } = await renderRoadtrip() + act(() => { result.current.handlePoiClick(poi() as never) }) + + expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 1 }) + // Not the full form: the popup is the point of the corridor. + expect(result.current.showPlaceForm).toBe(false) + }) + + it('FE-TP-ROAD-002: somewhere to sleep opens in overnight mode, ending on the next day of the trip', async () => { + // Ordered by the trip's own day order, not by array position: a day list can be + // sorted by anything, and "the next day" has to mean the next day driven. + seedTrip({ + days: [ + buildDay({ id: 7, day_number: 3 }), + buildDay({ id: 5, day_number: 1, date: '2025-06-01' }), + buildDay({ id: 6, day_number: 2, date: '2025-06-02' }), + ], + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + + const { result } = await renderRoadtrip() + act(() => { result.current.handlePoiClick(poi({ category: 'hotel' }) as never) }) + + const overnight = result.current.stopDraft?.overnight + expect(overnight?.defaultEndDayId).toBe(6) + expect(overnight?.days.map(d => d.id)).toEqual([5, 6, 7]) + }) + + it('FE-TP-ROAD-003: on the last day of the trip the night ends where it began', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + + const { result } = await renderRoadtrip() + act(() => { result.current.handlePoiClick(poi({ category: 'campsite' }) as never) }) + + expect(result.current.stopDraft?.overnight?.defaultEndDayId).toBe(5) + }) + + it('FE-TP-ROAD-004: a fuel stop gets no overnight mode, so the popup stays a dwell dialog', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + + const { result } = await renderRoadtrip() + act(() => { result.current.handlePoiClick(poi() as never) }) + + expect(result.current.stopDraft?.overnight).toBeUndefined() + }) + + it('FE-TP-ROAD-005: an ordinary map POI still goes to the full form, carrying the day being driven', async () => { + // Without the day it lands in the unplanned pool, and road trip mode shows no pool, + // so a just-added place would disappear without a trace. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + + const { result } = await renderRoadtrip() + const { alongKm: _drop, offRouteKm: _drop2, ...plain } = poi() + act(() => { result.current.handlePoiClick(plain as never) }) + + expect(result.current.stopDraft).toBeNull() + expect(result.current.showPlaceForm).toBe(true) + expect(result.current.placeFormDayId).toBe(5) + }) + + it('FE-TP-ROAD-006: a reader who may not add places gets nothing at all', async () => { + asReader('place_edit') + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + + const { result } = await renderRoadtrip() + act(() => { result.current.handlePoiClick(poi() as never) }) + + expect(result.current.stopDraft).toBeNull() + expect(result.current.showPlaceForm).toBe(false) + }) +}) + +describe('useTripPlanner road trip: saving a hit', () => { + const openDraft = async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.insertIndexFor.mockReturnValue(1) + const rendered = await renderRoadtrip() + act(() => { rendered.result.current.handlePoiClick(poi() as never) }) + return rendered + } + + it('FE-TP-ROAD-007: the place, its position and the via correction go in that order, then the day re-routes', async () => { + rt.vias.byDay = { 5: [via(1, 5, 1)] } + const { result } = await openDraft() + + await act(async () => { await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 15 }) }) + + expect(actions.addPlace).toHaveBeenCalledWith(42, expect.objectContaining({ + name: 'Rasthof Dammer Berge', stop_type: 'fuel', duration_minutes: 15, osm_id: 'node/1', + })) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 900, 1) + // Awaited before the re-route: a correction landing after it would draw the wrong + // road first and the right one a moment later. + expect(rt.vias.reanchor).toHaveBeenCalledWith(5, expect.anything()) + expect(updateRouteForDay).toHaveBeenCalledWith(5) + expect(result.current.stopDraft).toBeNull() + expect(toasts.some(t => t.type === 'success')).toBe(true) + }) + + it('FE-TP-ROAD-008: a failed write is said out loud and the draft stays open', async () => { + const { result } = await openDraft() + actions.addPlace.mockRejectedValue(new Error('disk full')) + + await act(async () => { await result.current.saveStopDraft({ stopType: null, dwellMinutes: 0 }) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'disk full')).toBe(true) + expect(result.current.stopDraft).not.toBeNull() + }) + + it('FE-TP-ROAD-009: saving with nothing open writes nothing', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.saveStopDraft({ stopType: 'fuel', dwellMinutes: 10 }) }) + + expect(actions.addPlace).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-010: a night writes the place, its day and the booking, then reloads the bookings', async () => { + const { result } = await openDraft() + + await act(async () => { + await result.current.saveStopDraftAsNight({ endDayId: 6, checkIn: '15:00', checkOut: '10:00' }) + }) + + // No stop_type and no dwell: a hotel is where the day ends, not something the drive + // passes through, so it keeps its number in the chain. + const [, payload] = actions.addPlace.mock.calls[0] as unknown as [number, Record] + expect(payload.stop_type).toBeUndefined() + expect(payload.duration_minutes).toBeUndefined() + expect(accommodationsApi.create).toHaveBeenCalledWith(42, { + place_id: 900, start_day_id: 5, end_day_id: 6, check_in: '15:00', check_out: '10:00', + }) + expect(accommodationRepo.list).toHaveBeenCalled() + expect(updateRouteForDay).toHaveBeenCalledWith(5) + }) + + it('FE-TP-ROAD-011: times nobody filled in are left out rather than sent empty', async () => { + // A hotel found on a map has no idea when its reception opens, and the server + // stores null. + const { result } = await openDraft() + + await act(async () => { + await result.current.saveStopDraftAsNight({ endDayId: 6, checkIn: '', checkOut: '' }) + }) + + expect(accommodationsApi.create).toHaveBeenCalledWith(42, { + place_id: 900, start_day_id: 5, end_day_id: 6, + }) + }) + + it('FE-TP-ROAD-012: a booking that will not save is reported and the draft is kept', async () => { + const { result } = await openDraft() + vi.mocked(accommodationsApi.create).mockRejectedValue(new Error('no room')) + + await act(async () => { + await result.current.saveStopDraftAsNight({ endDayId: 6, checkIn: '', checkOut: '' }) + }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'no room')).toBe(true) + expect(result.current.stopDraft).not.toBeNull() + }) + + it('FE-TP-ROAD-013: asking for the full form carries the kind and the dwell the popup had worked out', async () => { + // Leaving them behind is what turned a fuel stop into a numbered destination on the + // way to the full form, silently and in every total. + const { result } = await openDraft() + + act(() => { result.current.stopDraftToForm({ stopType: 'fuel', dwellMinutes: 20 }) }) + + expect(result.current.stopDraft).toBeNull() + expect(result.current.showPlaceForm).toBe(true) + expect(result.current.prefillCoords).toMatchObject({ stop_type: 'fuel', duration_minutes: 20 }) + expect(result.current.placeFormDayId).toBe(5) + }) + + it('FE-TP-ROAD-014: handing over with nothing decided leaves both fields empty', async () => { + const { result } = await openDraft() + + act(() => { result.current.stopDraftToForm() }) + + expect(result.current.prefillCoords).toMatchObject({ stop_type: null, duration_minutes: undefined }) + }) + + it('FE-TP-ROAD-015: a place on this trip from the same OSM object is named in the popup', async () => { + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 })], + places: [buildPlace({ id: 3, name: 'Aral A1', osm_id: 'node/1' })], + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + act(() => { result.current.handlePoiClick(poi() as never) }) + + expect(result.current.stopDraftDuplicate).toBe('Aral A1') + }) + + it('FE-TP-ROAD-016: with nothing open there is nothing to warn about', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + expect(result.current.stopDraftDuplicate).toBeNull() + }) +}) + +describe('useTripPlanner road trip: what the rail moves', () => { + const dayWithStops = () => { + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })], + assignments: { + '5': [stopAt(11, 5, 0), placeless(12, 5, 1), stopAt(13, 5, 2), stopAt(14, 5, 3)], + '6': [stopAt(21, 6, 0)], + }, + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + } + + it('FE-TP-ROAD-017: a reorder rebuilds the day from its COMPLETE list, keeping the rows the rail hides', async () => { + // Both reorderAssignments and the WebSocket handler rebuild the day purely from the + // ids they are given, so a row left out here vanishes for every session watching. + dayWithStops() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 14, 0) }) + + const [, , ids] = actions.reorderAssignments.mock.calls[0] as unknown as [number, number, number[]] + expect(ids).toHaveLength(4) + expect(ids).toContain(12) + expect(ids[0]).toBe(14) + }) + + it('FE-TP-ROAD-018: the rail index counts stops, not rows, so it maps onto the full list', async () => { + dayWithStops() + const { result } = await renderRoadtrip() + + // Rail index 1 is assignment 13: the placeless row was never in that space. + await act(async () => { await result.current.reorderRoadtripStop(5, 11, 1) }) + + const [, , ids] = actions.reorderAssignments.mock.calls[0] as unknown as [number, number, number[]] + expect(ids).toEqual([12, 13, 11, 14]) + }) + + it('FE-TP-ROAD-019: the vias are re-anchored in the rail own index space', async () => { + dayWithStops() + rt.vias.byDay = { 5: [via(1, 5, 0), via(2, 5, 1)] } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 11, 2) }) + + expect(rt.vias.reanchor).toHaveBeenCalledWith(5, expect.anything()) + expect(updateRouteForDay).toHaveBeenCalledWith(5) + }) + + it('FE-TP-ROAD-020: dropping a stop on itself changes nothing', async () => { + dayWithStops() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 11, 0) }) + + expect(actions.reorderAssignments).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-021: an assignment the day does not hold is not moved', async () => { + dayWithStops() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 999, 0) }) + + expect(actions.reorderAssignments).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-022: a failed reorder is reported rather than swallowed', async () => { + dayWithStops() + actions.reorderAssignments.mockRejectedValue(new Error('conflict')) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 14, 0) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'conflict')).toBe(true) + }) + + it('FE-TP-ROAD-023: a reader may not reorder the drive', async () => { + asReader('day_edit') + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 })], + assignments: { '5': [stopAt(11, 5, 0), stopAt(13, 5, 1)] }, + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.reorderRoadtripStop(5, 11, 1) }) + + expect(actions.reorderAssignments).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-024: moving to another day corrects BOTH days and re-routes both', async () => { + // The stop leaves a gap on one side and opens one on the other, so each day needs + // its own correction. + dayWithStops() + rt.vias.byDay = { 5: [via(1, 5, 1)], 6: [via(2, 6, 0)] } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripStopToDay(5, 13, 6, 1) }) + + expect(actions.moveAssignment).toHaveBeenCalledWith(42, 13, 5, 6, expect.any(Number)) + expect(rt.vias.reanchor).toHaveBeenCalledWith(5, expect.anything()) + expect(rt.vias.reanchor).toHaveBeenCalledWith(6, expect.anything()) + expect(updateRouteForDay).toHaveBeenCalledWith(5) + expect(updateRouteForDay).toHaveBeenCalledWith(6) + }) + + it('FE-TP-ROAD-025: a move onto the day it is already on is not a move', async () => { + dayWithStops() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripStopToDay(5, 13, 5, 0) }) + + expect(actions.moveAssignment).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-026: a day the rail draws no drive for can still be dropped onto', async () => { + // A day with one stop or none is exactly what a stop gets moved onto when a leg + // turns out to be too long. + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 9, day_number: 4 })], + assignments: { '5': [stopAt(11, 5, 0), stopAt(13, 5, 1)], '9': [] }, + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripStopToDay(5, 13, 9, 0) }) + + expect(actions.moveAssignment).toHaveBeenCalledWith(42, 13, 5, 9, 0) + }) + + it('FE-TP-ROAD-027: a failed move is reported', async () => { + dayWithStops() + actions.moveAssignment.mockRejectedValue(new Error('gone')) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripStopToDay(5, 13, 6, 0) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'gone')).toBe(true) + }) + + it('FE-TP-ROAD-028: a reader may not move a stop between days', async () => { + asReader('day_edit') + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })], + assignments: { '5': [stopAt(11, 5, 0)], '6': [] }, + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripStopToDay(5, 11, 6, 0) }) + + expect(actions.moveAssignment).not.toHaveBeenCalled() + }) +}) + +describe('useTripPlanner road trip: one stop at a time', () => { + const oneStop = () => { + seedTrip({ + days: [buildDay({ id: 5, day_number: 1 })], + assignments: { '5': [stopAt(11, 5, 0)] }, + }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + } + + it('FE-TP-ROAD-029: making a stop a pause writes one field on one place', async () => { + oneStop() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStopKind(1011, 'rest_area') }) + + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { stop_type: 'rest_area' }) + }) + + it('FE-TP-ROAD-030: turning it back into a destination sends null, and a failure is reported', async () => { + oneStop() + actions.updatePlace.mockRejectedValue(new Error('locked')) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStopKind(1011, null) }) + + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { stop_type: null }) + expect(toasts.some(t => t.type === 'error' && t.message === 'locked')).toBe(true) + }) + + it('FE-TP-ROAD-031: clearing a stay sends a zero, never a null', async () => { + // The update statement folds a null into "leave it alone", so a null could give a + // stop a stay and never take one away. + oneStop() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStay(1011, 0) }) + + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1011, { duration_minutes: 0 }) + }) + + it('FE-TP-ROAD-032: a reader may not set a stay', async () => { + asReader('place_edit') + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.setRoadtripStay(1011, 45) }) + expect(actions.updatePlace).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-033: a driving limit goes straight to the settings, and a failure is said', async () => { + oneStop() + const updateSettings = vi.fn(async () => {}) + useSettingsStore.setState({ updateSettings } as never) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 180) }) + expect(updateSettings).toHaveBeenCalledWith({ roadtrip_max_leg_minutes: 180 }) + + updateSettings.mockRejectedValue(new Error('nope')) + await act(async () => { await result.current.saveRoadtripLimit('roadtrip_max_leg_minutes', 90) }) + expect(toasts.some(t => t.type === 'error')).toBe(true) + }) +}) + +describe('useTripPlanner road trip: other ways of driving a leg', () => { + const routedDay = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [ + { lat: 53.55, lng: 9.99, name: 'Hamburg' }, + { lat: 52.52, lng: 13.4, name: 'Berlin' }, + ], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }] + } + + it('FE-TP-ROAD-034: asking passes the vias of THAT leg, so the road being driven is offered too', async () => { + routedDay() + rt.vias.byDay = { 5: [via(1, 5, 0, 1), via(2, 5, 0, 0), via(3, 5, 1)] } + const { result } = await renderRoadtrip() + + act(() => { result.current.askRouteAlternatives(5, 0) }) + + const [dayId, legIndex, , , profile, legVias] = rt.alt.ask.mock.calls[0] as [number, number, unknown, unknown, string, Array<{ id: number }>] + expect([dayId, legIndex, profile]).toEqual([5, 0, 'driving']) + // Only the vias on leg 0, and in the order the car passes them. + expect(legVias.map(v => v.id)).toEqual([2, 1]) + }) + + it('FE-TP-ROAD-035: asking again for the leg already open closes it instead', async () => { + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false } + const { result } = await renderRoadtrip() + + act(() => { result.current.askRouteAlternatives(5, 0) }) + + expect(rt.alt.close).toHaveBeenCalled() + expect(rt.alt.ask).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-036: a leg with nothing at its far end is not a leg', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { result.current.askRouteAlternatives(5, 1) }) + + expect(rt.alt.ask).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-037: choosing the road already driven changes nothing but the picker', async () => { + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [{ current: true }], loading: false, error: false } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.chooseRouteAlternative(0) }) + + expect(rt.vias.add).not.toHaveBeenCalled() + expect(rt.vias.addMany).not.toHaveBeenCalled() + expect(rt.alt.close).toHaveBeenCalled() + }) + + it('FE-TP-ROAD-038: the router own preference clears the leg in ONE write', async () => { + // One delete per via meant a full trip re-route between each of them, so undoing a + // detour with three vias drew three routes. + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [{ direct: true }], loading: false, error: false } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.chooseRouteAlternative(0) }) + + expect(rt.vias.addMany).toHaveBeenCalledWith(5, [], [0]) + expect(rt.alt.close).toHaveBeenCalled() + }) + + it('FE-TP-ROAD-039: a clear that fails leaves the picker open and says so', async () => { + // Swallowing it closed the picker on a leg that still carries its via and still + // routes the old way, so the traveller believed they had undone the detour. + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [{ direct: true }], loading: false, error: false } + rt.vias.addMany.mockRejectedValue(new Error('offline')) + const { result } = await renderRoadtrip() + // The mode starts off until the addon feed answers, and that effect closes the + // picker once on the way in. Measure from there. + rt.alt.close.mockClear() + + await act(async () => { await result.current.chooseRouteAlternative(0) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'offline')).toBe(true) + expect(rt.alt.close).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-040: another road is stored as a via where it differs most, not as a polyline', async () => { + routedDay() + rt.alt.open = { + dayId: 5, index: 0, loading: false, error: false, + routes: [{ divergence: { lat: 53.1, lng: 11.9 } }], + } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.chooseRouteAlternative(0) }) + + expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.1, 11.9) + expect(rt.alt.close).toHaveBeenCalled() + }) + + it('FE-TP-ROAD-041: a via that will not save leaves the picker open', async () => { + routedDay() + rt.alt.open = { + dayId: 5, index: 0, loading: false, error: false, + routes: [{ divergence: { lat: 53.1, lng: 11.9 } }], + } + rt.vias.add.mockRejectedValue(new Error('rejected')) + const { result } = await renderRoadtrip() + rt.alt.close.mockClear() + + await act(async () => { await result.current.chooseRouteAlternative(0) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'rejected')).toBe(true) + expect(rt.alt.close).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-042: choosing an index nothing was offered at does nothing', async () => { + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false } + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.chooseRouteAlternative(3) }) + + expect(rt.vias.add).not.toHaveBeenCalled() + expect(rt.vias.addMany).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-043: the overlays and the frame come from the open leg, and empty when it closes', async () => { + routedDay() + rt.alt.open = { + dayId: 5, index: 0, loading: false, error: false, + routes: [ + { coordinates: [[53.5, 10], [52.5, 13]], distance: 290_000, duration: 10_800, divergence: { lat: 53, lng: 11 } }, + { coordinates: [[53.5, 10], [53, 12], [52.5, 13]], distance: 310_000, duration: 11_400, divergence: { lat: 53, lng: 12 } }, + ], + } + const { result } = await renderRoadtrip() + + expect(result.current.alternativeOverlays).toHaveLength(2) + // Derived from the overlays, not from the two endpoints, so the frame holds the + // whole of every alternative including one that swings far off the direct line. + expect(result.current.alternativeFocusPoints.length).toBeGreaterThan(2) + }) + + it('FE-TP-ROAD-044: leaving road trip mode closes the picker over the map', async () => { + // The switch sits in the left sidebar and is reachable while the bar is open, and + // the overlay depends only on the picker: flipping the mode off left pale blue + // alternatives drawn on an ordinary planner map with no way to dismiss them. + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false } + const { result } = await renderRoadtrip() + rt.alt.close.mockClear() + + act(() => { result.current.toggleRoadtripMode() }) + + await waitFor(() => expect(rt.alt.close).toHaveBeenCalled()) + expect(result.current.roadtripActive).toBe(false) + }) + + it('FE-TP-ROAD-045: closing the picker clears the road it had lit up', async () => { + routedDay() + rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false } + const { result, rerender } = await renderRoadtrip() + + act(() => { result.current.setHighlightedAlternative(1) }) + expect(result.current.highlightedAlternative).toBe(1) + + rt.alt.open = null + rerender() + + await waitFor(() => expect(result.current.highlightedAlternative).toBeNull()) + }) +}) + +describe('useTripPlanner road trip: shaping the drive on the map', () => { + const twoRoutedDays = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [ + { + dayId: 5, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }, + { + dayId: 6, + stops: [{ lat: 48.13, lng: 11.58 }, { lat: 47.8, lng: 13.05 }], + geometry: [[48.13, 11.58], [47.9, 12.3], [47.8, 13.05]], + }, + ] + } + + it('FE-TP-ROAD-046: a click on the drawn route lands on the day whose line it is nearest', async () => { + twoRoutedDays() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53.0, 11.5) }) + + expect(rt.vias.add).toHaveBeenCalledWith(5, 0, 53.0, 11.5) + }) + + it('FE-TP-ROAD-047: a click that landed on some other line is not a via anywhere', async () => { + twoRoutedDays() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(41.9, 12.5) }) + + expect(rt.vias.add).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-048: a day with no drawn line is skipped rather than measured against', async () => { + twoRoutedDays() + rt.routes.days = [{ dayId: 5, stops: [], geometry: [] }, rt.routes.days[1]] + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(47.9, 12.3) }) + + expect(rt.vias.add).toHaveBeenCalledWith(6, 0, 47.9, 12.3) + }) + + it('FE-TP-ROAD-049: a via that will not save is reported', async () => { + twoRoutedDays() + rt.vias.add.mockRejectedValue(new Error('read only')) + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53.0, 11.5) }) + + expect(toasts.some(t => t.type === 'error' && t.message === 'read only')).toBe(true) + }) + + it('FE-TP-ROAD-050: a reader may not shape the drive at all', async () => { + asReader('day_edit') + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ dayId: 5, stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], geometry: [[53.55, 9.99], [52.52, 13.4]] }] + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.addRoadtripVia(53.0, 11.5) }) + await act(async () => { await result.current.moveRoadtripVia(5, 1, 53.1, 11.6) }) + await act(async () => { await result.current.removeRoadtripVia(5, 1) }) + + expect(rt.vias.add).not.toHaveBeenCalled() + expect(rt.vias.move).not.toHaveBeenCalled() + expect(rt.vias.remove).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-051: dragging a via redraws the route through its new position', async () => { + twoRoutedDays() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) }) + expect(rt.vias.move).toHaveBeenCalledWith(5, 3, 53.2, 11.7) + + rt.vias.move.mockRejectedValue(new Error('stale')) + await act(async () => { await result.current.moveRoadtripVia(5, 3, 53.2, 11.7) }) + expect(toasts.some(t => t.type === 'error' && t.message === 'stale')).toBe(true) + }) + + it('FE-TP-ROAD-052: removing a via lets the drive take the direct road again', async () => { + twoRoutedDays() + const { result } = await renderRoadtrip() + + await act(async () => { await result.current.removeRoadtripVia(5, 3) }) + expect(rt.vias.remove).toHaveBeenCalledWith(5, 3) + + rt.vias.remove.mockRejectedValue(new Error('vanished')) + await act(async () => { await result.current.removeRoadtripVia(5, 3) }) + expect(toasts.some(t => t.type === 'error' && t.message === 'vanished')).toBe(true) + }) +}) + +describe('useTripPlanner road trip: dropping a hit where it belongs', () => { + const corridorWithHit = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.visible = [poi({ osm_id: 'node/7' })] + rt.corridor.search.spine = [ + { lat: 53.55, lng: 9.99 }, + { lat: 53.0, lng: 11.5 }, + { lat: 52.52, lng: 13.4 }, + ] + rt.corridor.stopsAlongKm = [0, 290] + rt.corridor.widthKm = 5 + } + + it('FE-TP-ROAD-053: the drop coordinate decides, not the projection the corridor made', async () => { + // The two differ whenever a drive passes near the same spot twice, and the + // automatic projection can only pick one of them. + corridorWithHit() + const { result } = await renderRoadtrip() + + act(() => { result.current.dropPoiOnRoute('node/7', 53.0, 11.5) }) + + expect(result.current.stopDraft).toMatchObject({ dayId: 5, dayNumber: 1 }) + expect(result.current.stopDraft?.poi.osm_id).toBe('node/7') + }) + + it('FE-TP-ROAD-054: a drop nowhere near the drive is ignored rather than guessed at', async () => { + corridorWithHit() + const { result } = await renderRoadtrip() + + act(() => { result.current.dropPoiOnRoute('node/7', 41.9, 12.5) }) + + expect(result.current.stopDraft).toBeNull() + }) + + it('FE-TP-ROAD-055: a hit the corridor is no longer showing cannot be dropped', async () => { + corridorWithHit() + const { result } = await renderRoadtrip() + + act(() => { result.current.dropPoiOnRoute('node/999', 53.0, 11.5) }) + + expect(result.current.stopDraft).toBeNull() + }) + + it('FE-TP-ROAD-056: a reader may not drop one at all', async () => { + asReader('place_edit') + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.corridor.visible = [poi({ osm_id: 'node/7' })] + rt.corridor.search.spine = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }] + const { result } = await renderRoadtrip() + + act(() => { result.current.dropPoiOnRoute('node/7', 53.0, 11.5) }) + + expect(result.current.stopDraft).toBeNull() + }) +})