Files
TREK/client/src/pages/TripPlannerPage.wiring.test.tsx
T
Maurice a7f5a114e6 fix(tours): the checklist keeps to its tour, its state and its focus
A decision answered after another tour was opened no longer replaces that
tour's checklist. A decision made offline on a checklist already older than
the route stays marked as such, and once online a bench from a write made
offline waits for the queue to replay, then takes in the server's. While
the checklist reads again it stays in view, locked, so a panel open on the
map keeps its note. A saved tour turned into a paddle tour asks to be saved
first, a vanished obstacle stays among the rows, Next follows the list
order and keeps the focus in the panel, a note closes only once saved, the
earlier choice in review is named for screen readers too, planning a
portage is offered to editors only, and the map card scrolls within the
screen. A tooltip of several lines breaks at them. A page test waits for
the tours row instead of reading it at once.
2026-10-17 02:07:22 +00:00

1777 lines
80 KiB
TypeScript

// FE-PAGE-TPW-001 to FE-PAGE-TPW-074
//
// The planner page is a wiring container: everything stateful lives in
// useTripPlanner (covered in src/pages/tripPlanner/useTripPlanner.test.tsx).
// Here the hook is replaced by a controllable fixture so every branch of the
// layout — panels, portals, tabs, modals — and every inline callback the page
// hands its children can be driven directly.
import React from 'react';
import {
buildAssignment,
buildPackingItem,
buildPlace,
buildReservation,
buildTodoItem,
buildTrip,
buildUser,
} from '../../tests/helpers/factories';
import { act, cleanup, fireEvent, render, screen, waitFor } from '../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { assignmentsApi } from '../api/client';
import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import { useTripStore } from '../store/tripStore';
import type { Reservation, Settings } from '../types';
import { analyzeRouteGeometry } from '../utils/routeGeometry';
import TripPlannerPage from './TripPlannerPage';
import { baseState, day, place, trip, type HookState } from './TripPlannerPage.wiring.fixture';
// ── Component stubs ───────────────────────────────────────────────────────────
// Each stub records the props it was rendered with so the page's inline
// callbacks can be invoked from the test.
type Props = Record<string, unknown>
const captured: Record<string, Props> = {}
const plannerActions = vi.hoisted(() => ({
viewGpxTour: vi.fn(),
addWaypoint: vi.fn(),
setWaypointPosition: vi.fn(() => true),
openTour: vi.fn(),
startNewTour: vi.fn(),
forgetDeletedTour: vi.fn((_placeId: number) => undefined),
saveOutcome: null as unknown,
mapFocusKey: 0,
newDraftGeneration: 0,
mode: { type: 'neutral' } as unknown,
isSaving: false,
waypoints: [] as unknown,
routeProfileFocus: null as unknown,
readOnlyGpxTour: null as unknown,
readOnlyGpxAnalysis: null as unknown,
}))
function stub(name: string, testId?: string) {
return (props: Props) => {
captured[name] = props
return testId ? React.createElement('div', { 'data-testid': testId }) : null
}
}
/** The functional updater a setter spy was last called with. */
function updaterOf<T>(setter: unknown): (prev: T) => T {
const calls = (setter as { mock: { calls: unknown[][] } }).mock.calls
return calls[calls.length - 1][0] as (prev: T) => T
}
type AnyProp = ((...args: unknown[]) => unknown) & Record<string, unknown>
/** The last-rendered props of a stub, typed loosely so callbacks can be invoked. */
function props(name: string): Record<string, AnyProp> {
return (captured[name] ?? {}) as Record<string, AnyProp>
}
vi.mock('../components/Map/MapViewAuto', () => ({ MapViewAuto: stub('map', 'map-view') }))
vi.mock('../components/Map/MapCompassPill', () => ({ MapCompassPill: stub('compass', 'compass-pill') }))
// Every pill mount records its props here, so a test can check each one of them.
const poiPillMounts = vi.hoisted(() => [] as Record<string, unknown>[])
const poiCategories = vi.hoisted(() => ({ core: [], plugin: [] }))
vi.mock('../components/Map/PoiCategoryPill', () => ({
default: (props: Record<string, unknown>) => {
captured.poiPill = props
poiPillMounts.push(props)
return React.createElement('div', { 'data-testid': 'poi-pill' })
},
}))
vi.mock('../components/Map/usePoiExplore', () => ({
usePoiExplore: () => ({
categories: poiCategories,
active: [], pois: [], loadingKeys: [], errorKeys: [], moved: false,
toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
}),
}))
vi.mock('../components/Layout/Navbar', () => ({ default: stub('navbar', 'navbar') }))
vi.mock('../components/shared/SlidingTabs', () => ({ default: stub('tabs', 'sliding-tabs'), TabLabel: () => null }))
vi.mock('../components/shared/TripLoadingSplash', () => ({
default: ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'splash', role: 'status' }, title ?? 'TREK'),
}))
const confirmDialogs: Props[] = []
vi.mock('../components/shared/ConfirmDialog', () => ({
default: (p: Props) => { confirmDialogs.push(p); return null },
}))
vi.mock('../components/Planner/DayPlanSidebar', () => ({ default: stub('dayPlan', 'day-plan-sidebar') }))
vi.mock('../components/Planner/PlacesSidebar', () => ({ default: stub('places', 'places-sidebar') }))
vi.mock('../components/Planner/PlaceInspector', () => ({ default: stub('inspector', 'place-inspector') }))
vi.mock('../components/Planner/DayDetailPanel', () => ({ default: stub('dayDetail', 'day-detail-panel') }))
vi.mock('../components/Planner/PlaceFormModal', () => ({ default: stub('placeForm') }))
vi.mock('../components/Planner/ReservationModal', () => ({ ReservationModal: stub('reservationModal') }))
vi.mock('../components/Planner/TransportModal', () => ({ TransportModal: stub('transportModal', 'transport-modal') }))
vi.mock('../components/Planner/TransitJourneyModal', () => ({ default: stub('transitModal', 'transit-modal') }))
vi.mock('../components/Planner/bookings/BookingDetailHost', () => ({ BookingDetailPopup: stub('bookingDetail', 'booking-detail') }))
vi.mock('../components/Planner/BookingImportModal', () => ({ default: stub('bookingImport') }))
vi.mock('../components/Planner/AirTrailImportModal', () => ({ default: stub('airtrailImport') }))
vi.mock('../components/Planner/ReservationsPanel', () => ({ default: stub('reservationsPanel', 'reservations-panel') }))
vi.mock('../components/Planner/TripWarningsBanner', () => ({ default: stub('warnings', 'trip-warnings') }))
vi.mock('../components/Tours/TourDetailDialog', () => ({ default: stub('tourDetail', 'tour-detail-dialog') }))
vi.mock('../components/Tours/planner/useTourPlanner', async () => {
const { hikePlannerDefaults } = await import('../../tests/helpers/tourPlannerStub')
const defaults = hikePlannerDefaults()
return {
useTourPlanner: () => ({
...defaults,
route: null,
waypoints: plannerActions.waypoints,
routeProfileFocus: plannerActions.routeProfileFocus,
name: '',
startNewTour: plannerActions.startNewTour,
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
elevationProfileExpanded: true,
toggleElevationProfile: vi.fn(),
newDraftGeneration: plannerActions.newDraftGeneration,
mapFocusKey: plannerActions.mapFocusKey,
mode: plannerActions.mode,
isSaving: plannerActions.isSaving,
selectedWaypointId: null,
editingPlaceId: null,
saveOutcome: plannerActions.saveOutcome,
hasUnsavedChanges: false,
setSelectedWaypointId: vi.fn(),
mapBaseLayer: 'map',
setMapBaseLayer: vi.fn(),
openTour: plannerActions.openTour,
viewGpxTour: plannerActions.viewGpxTour,
addWaypoint: plannerActions.addWaypoint,
setWaypointPosition: plannerActions.setWaypointPosition,
closeGpxTour: vi.fn(),
readOnlyGpxTour: plannerActions.readOnlyGpxTour,
readOnlyGpxAnalysis: plannerActions.readOnlyGpxAnalysis,
discard: vi.fn(),
forgetDeletedTour: plannerActions.forgetDeletedTour,
}),
}
})
vi.mock('../components/Trips/TripFormModal', () => ({ default: stub('tripForm') }))
vi.mock('../components/Trips/TripMembersModal', () => ({ default: stub('membersModal') }))
vi.mock('../components/Packing/PackingListPanel', () => ({ default: stub('packingPanel', 'packing-list-panel') }))
vi.mock('../components/Packing/ApplyTemplateButton', () => ({ default: stub('applyTemplate', 'apply-template') }))
vi.mock('../components/Packing/PackingExportMenu', () => ({ default: stub('exportMenu', 'export-menu') }))
vi.mock('../components/Todo/TodoListPanel', () => ({ default: stub('todoPanel', 'todo-list-panel') }))
vi.mock('../components/Files/FileManager', () => ({ default: stub('fileManager', 'file-manager') }))
vi.mock('../components/Budget/CostsPanel', () => ({
default: stub('costsPanel', 'costs-panel'),
ExpenseModal: stub('expenseModal', 'expense-modal'),
}))
vi.mock('../components/Collab/CollabPanel', () => ({ default: stub('collabPanel', 'collab-panel') }))
vi.mock('../components/Plugins/PluginFrame', () => ({ default: stub('pluginFrame', 'plugin-frame') }))
// ── useTripPlanner fixture (TripPlannerPage.wiring.fixture.ts) ───────────────────
let hookState: HookState
vi.mock('./tripPlanner/useTripPlanner', () => ({
useTripPlanner: () => hookState,
}))
function setSettings(patch: Partial<Settings>) {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, ...patch } })
}
function renderPage(overrides: HookState = {}) {
hookState = { ...hookState, ...overrides }
return render(<TripPlannerPage />)
}
/** The page on the Tours planner tab, with the Tours addon on. */
function renderToursPage(overrides: HookState = {}) {
const enabledAddons = { packing: true, budget: true, documents: true, collab: true, tours: true }
return renderPage({ activeTab: 'tour-planner', toursEnabled: true, enabledAddons, ...overrides })
}
function mockStartNewTourTransition() {
plannerActions.startNewTour.mockImplementation(() => {
plannerActions.mode = { type: 'new-draft' }
plannerActions.waypoints = []
plannerActions.saveOutcome = null
plannerActions.mapFocusKey += 1
plannerActions.newDraftGeneration += 1
})
}
beforeEach(() => {
vi.clearAllMocks()
resetAllStores()
for (const key of Object.keys(captured)) delete captured[key]
confirmDialogs.length = 0
plannerActions.viewGpxTour.mockReset()
plannerActions.addWaypoint.mockReset()
plannerActions.setWaypointPosition.mockReset().mockReturnValue(true)
plannerActions.openTour.mockReset()
plannerActions.startNewTour.mockReset()
plannerActions.forgetDeletedTour.mockReset()
plannerActions.saveOutcome = null
plannerActions.mapFocusKey = 0
plannerActions.newDraftGeneration = 0
plannerActions.mode = { type: 'neutral' }
plannerActions.isSaving = false
plannerActions.waypoints = []
plannerActions.routeProfileFocus = null
plannerActions.readOnlyGpxTour = null
plannerActions.readOnlyGpxAnalysis = null
hookState = baseState()
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser({ id: 5 }) })
useTripStore.setState({ loadBudgetItems: vi.fn(async () => undefined) } as never)
})
describe('TripPlannerPage — shell', () => {
it('UI-R2: Tours Planner rails use shared resize bounds, touch handles and keyboard nudges', () => {
const { container } = renderToursPage()
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(handles).toHaveLength(2)
expect(Array.from(handles, handle => handle.getAttribute('data-resize'))).toEqual(['left', 'right'])
for (const handle of handles) {
expect(handle).toHaveAttribute('aria-valuemin', '200')
expect(handle).toHaveAttribute('aria-valuemax', '520')
expect(handle.style.touchAction).toBe('none')
expect(handle).toHaveAttribute('aria-label')
}
fireEvent.touchStart(handles[0])
expect(hookState.startResizeLeft).toHaveBeenCalled()
fireEvent.keyDown(handles[1], { key: 'ArrowLeft' })
expect(hookState.nudgeRight).toHaveBeenCalledWith(16)
expect(props('map').leftWidth).toBe(320)
expect(props('map').rightWidth).toBe(320)
})
it('UI-R2: collapsed Tours rails retain semantic restore controls and report zero map insets', () => {
renderToursPage({ leftHidden: true, rightHidden: true })
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
expect(screen.getByRole('button', { name: 'trip.mobilePlan' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'tours.planner.tripTours' })).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: 'trip.mobilePlan' })[0]).toHaveClass('focus-visible:outline')
expect(document.querySelectorAll('div[role="separator"]')).toHaveLength(0)
})
it.each([
{ leftHidden: false, rightHidden: true, visibleSide: 'left', leftInset: 300, rightInset: 0 },
{ leftHidden: true, rightHidden: false, visibleSide: 'right', leftInset: 0, rightInset: 300 },
])('UI-R2: the 768-1023px panel state shows only its selected rail ($visibleSide)', ({ leftHidden, rightHidden, visibleSide, leftInset, rightInset }) => {
const { container } = renderToursPage({ narrowPanels: true,
leftHidden, rightHidden, leftWidth: 300, rightWidth: 300, resizeMax: 300 })
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(handles).toHaveLength(1)
expect(handles[0]).toHaveAttribute('data-resize', visibleSide)
expect(handles[0]).toHaveAttribute('aria-valuemax', '300')
expect(props('map').leftWidth).toBe(leftInset)
expect(props('map').rightWidth).toBe(rightInset)
})
it('UI-R2: rail collapse changes only map insets, not the fit target or routed draft', () => {
const view = renderToursPage()
const focusPoints = props('map').focusPoints
const route = props('map').route
const fitKey = props('map').fitKey
hookState = { ...hookState, leftHidden: true, rightHidden: true }
view.rerender(<TripPlannerPage />)
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
expect(props('map').focusPoints).toBe(focusPoints)
expect(props('map').route).toBe(route)
expect(props('map').fitKey).toBe(fitKey)
expect(plannerActions.mode).toEqual({ type: 'neutral' })
})
it('FE-PAGE-TPW-066: Tours entry stays neutral when saved Tours exist and does not auto-select one', async () => {
const savedTour = {
place_id: 92,
name: 'Editable ridge tour',
tour_type: 'hike',
distance: 6,
elevation_gain: 300,
elevation_loss: 290,
duration: 120,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
}
renderToursPage({ tours: [savedTour] })
expect(await screen.findByText('Plan a tour')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Plan new tour' })).toBeInTheDocument()
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
// The tours list fills in after the rail: wait for its row instead of reading it at once.
expect(await screen.findByRole('option', { name: /Editable ridge tour/i })).not.toHaveAttribute('aria-selected', 'true')
expect(plannerActions.openTour).not.toHaveBeenCalled()
})
it('preserves the visible TOUR-PLANNER camera when the Plan a Tour button starts an empty draft', async () => {
const place = buildPlace({ id: 81, lat: 35.01, lng: 135.76 })
mockStartNewTourTransition()
const view = renderToursPage({
places: [place],
})
const mapElement = screen.getByTestId('map-view')
expect(props('map').focusPoints).toEqual([[place.lat, place.lng]])
fireEvent.click(await screen.findByRole('button', { name: 'Plan new tour' }))
expect(plannerActions.startNewTour).toHaveBeenCalledOnce()
view.rerender(<TripPlannerPage />)
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
expect(props('map').focusPoints).toEqual([])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').focusKey).toBe('tour:1');
expect(screen.getByTestId('map-view')).toBe(mapElement)
expect(props('map').onMapClick).toBeTypeOf('function')
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }))
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77)
})
it('preserves the visible TOUR-PLANNER camera when a new Tour starts after a save', async () => {
const savedTour = {
place_id: 82,
name: 'Saved Kyoto tour',
tour_type: 'hike',
distance: 6,
elevation_gain: 300,
elevation_loss: 290,
duration: 120,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
}
mockStartNewTourTransition()
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id }
plannerActions.waypoints = [
{ id: 'start', lat: 35.01, lng: 135.76, role: 'start' },
{ id: 'end', lat: 35.02, lng: 135.77, role: 'end' },
]
plannerActions.mapFocusKey = 8
plannerActions.saveOutcome = savedTour
const view = renderToursPage({
tours: [savedTour],
places: [buildPlace({ id: savedTour.place_id, lat: 35.01, lng: 135.76 })],
})
const mapElement = screen.getByTestId('map-view')
expect(props('map').focusPoints).toEqual([[35.01, 135.76], [35.02, 135.77]])
act(() => { plannerActions.startNewTour() })
expect(plannerActions.startNewTour).toHaveBeenCalledOnce()
view.rerender(<TripPlannerPage />)
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
expect(props('map').focusPoints).toEqual([])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').focusKey).toBe('tour:9');
expect(screen.getByTestId('map-view')).toBe(mapElement)
expect(props('map').onMapClick).toBeTypeOf('function')
})
it('FE-PAGE-TPW-063: uses the last TRIP-PLAN viewport for neutral TOUR-PLANNER fallback', () => {
const first = buildPlace({ id: 81, lat: 47.1, lng: 11.2 })
const second = buildPlace({ id: 82, lat: 47.3, lng: 11.6 })
const view = renderToursPage({ places: [first, second] })
expect(props('map').focusPoints).toEqual([[47.1, 11.2], [47.3, 11.6]])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
expect(props('map').onMapClick).toBeUndefined()
plannerActions.mode = { type: 'new-draft' }
view.rerender(<TripPlannerPage />)
plannerActions.isSaving = true
view.rerender(<TripPlannerPage />)
expect(props('map').onMapClick).toBeUndefined()
expect(screen.getByTestId('map-view')).toBeInTheDocument()
plannerActions.isSaving = false
view.rerender(<TripPlannerPage />)
act(() => props('map').onMapClick({ latlng: { lat: 47.11, lng: 11.21 } }))
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(47.11, 11.21)
expect(props('map').plannerWaypoints).toEqual([])
hookState = { ...hookState, activeTab: 'plan' }
view.rerender(<TripPlannerPage />)
act(() => props('map').onViewportChange({ south: 46.9, west: 10.8, north: 47.5, east: 12.1 }))
// The place search looks where the plan map looks (#2620), and only while it is shown.
expect(useTripStore.getState().planMapViewport).toEqual({ tripId: 42, south: 46.9, west: 10.8, north: 47.5, east: 12.1 })
plannerActions.mode = { type: 'neutral' }
plannerActions.waypoints = []
hookState = { ...hookState, activeTab: 'tour-planner' }
view.rerender(<TripPlannerPage />)
expect(useTripStore.getState().planMapViewport).toBeNull()
expect(props('map').focusPoints).toEqual([[46.9, 10.8], [47.5, 12.1]])
expect(props('map').route).toBeNull()
expect(props('map').plannerWaypoints).toEqual([])
})
it('keeps editable fit targets stable while transient profile focus changes', () => {
const waypoints = [
{ id: 'start', lat: 47.1, lng: 11.2, role: 'start' },
{ id: 'end', lat: 47.3, lng: 11.6, role: 'end' },
]
plannerActions.mode = { type: 'edit-saved', placeId: 92 }
plannerActions.waypoints = waypoints
const view = renderToursPage()
const fitTargets = props('map').focusPoints
const route = props('map').route
const mapWaypoints = props('map').plannerWaypoints
plannerActions.routeProfileFocus = { distanceMeters: 10, elevationMeters: 100, lat: 47.11, lng: 11.21, sampleIndex: 1 }
view.rerender(<TripPlannerPage />)
expect(props('map').routeProfileFocus).toBe(plannerActions.routeProfileFocus)
expect(props('map').focusPoints).toBe(fitTargets)
expect(props('map').route).toBe(route)
expect(props('map').plannerWaypoints).toBe(mapWaypoints)
plannerActions.routeProfileFocus = { distanceMeters: 20, elevationMeters: 110, lat: 47.12, lng: 11.22, sampleIndex: 1 }
view.rerender(<TripPlannerPage />)
expect(props('map').focusPoints).toBe(fitTargets)
expect(props('map').route).toBe(route)
expect(props('map').plannerWaypoints).toBe(mapWaypoints)
plannerActions.waypoints = [...waypoints, { id: 'via', lat: 47.2, lng: 11.4, role: 'via' }]
view.rerender(<TripPlannerPage />)
expect(props('map').focusPoints).not.toBe(fitTargets)
expect(props('map').focusPoints).toEqual([[47.1, 11.2], [47.3, 11.6], [47.2, 11.4]])
})
it('FE-PAGE-TPW-064: falls back to the normal map default when there is no viewport or geo Place', () => {
renderToursPage({ places: [] })
expect(props('map').focusPoints).toEqual([])
expect(props('map').center).toBeUndefined()
expect(props('map').zoom).toBeUndefined()
})
it('FE-PAGE-TPW-065: Tours OFF stays on native Plan and does not create the Tours Planner hook', () => {
const view = renderPage({ activeTab: 'plan', toursEnabled: false })
expect(screen.getByTestId('map-view')).toBeInTheDocument()
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument()
hookState = { ...hookState, activeTab: 'plan' }
view.rerender(<TripPlannerPage />)
expect(screen.getByTestId('map-view')).toBeInTheDocument()
expect(screen.queryByTestId('tour-planner-rail')).not.toBeInTheDocument()
})
it('does not offer permanent Tour Delete from the desktop TRIP-PLAN detail', () => {
const tourPlace = buildPlace({ id: 91, name: 'Ridge walk', tour_place_id: 91 })
const selectedTour = {
place_id: 91,
name: tourPlace.name,
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 390,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: 2,
planned: true,
caution: false,
has_waypoints: true,
}
renderPage({
toursEnabled: true,
selectedPlace: tourPlace,
selectedPlaceId: tourPlace.id,
selectedTour,
})
expect(props('tourDetail').onDelete).toBeUndefined()
})
it('routes visible TOUR-PLANNER Tours-rail deletion through the existing confirmation', async () => {
const savedTour = {
place_id: 91,
name: 'Ridge walk',
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 390,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
has_waypoints: true,
}
const handleDeleteTour = vi.fn((placeId: number) => {
hookState = { ...hookState, deletePlaceId: placeId, deletePlaceIsTour: true }
})
const setDeletePlaceId = vi.fn((placeId: number | null) => {
hookState = { ...hookState, deletePlaceId: placeId }
})
const confirmDeletePlace = vi.fn(async (): Promise<number | null> => savedTour.place_id)
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id }
plannerActions.forgetDeletedTour.mockImplementation(placeId => {
const mode = plannerActions.mode as { placeId?: number }
if (mode.placeId === placeId) plannerActions.mode = { type: 'neutral' }
})
const view = renderToursPage({
tours: [savedTour],
handleDeleteTour,
setDeletePlaceId,
confirmDeletePlace,
})
fireEvent.click(await screen.findByRole('button', { name: 'Delete Ridge walk' }))
expect(handleDeleteTour).toHaveBeenCalledWith(savedTour.place_id)
view.rerender(<TripPlannerPage />)
let confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
expect(confirmation).toMatchObject({ title: 'common.delete', confirmLabel: 'tours.delete.confirmAction' })
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
const onClose = confirmation?.onClose as (() => void) | undefined
onClose?.()
expect(setDeletePlaceId).toHaveBeenCalledWith(null)
expect(confirmDeletePlace).not.toHaveBeenCalled()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }))
view.rerender(<TripPlannerPage />)
confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
const onConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined
await act(async () => { await onConfirm?.() })
expect(confirmDeletePlace).toHaveBeenCalledOnce()
expect(plannerActions.forgetDeletedTour).toHaveBeenCalledWith(savedTour.place_id)
view.rerender(<TripPlannerPage />)
expect(screen.getByText('Plan a tour')).toBeInTheDocument()
confirmDeletePlace.mockResolvedValue(null)
plannerActions.forgetDeletedTour.mockClear()
plannerActions.mode = { type: 'edit-saved', placeId: savedTour.place_id }
fireEvent.click(screen.getByRole('button', { name: 'Delete Ridge walk' }))
view.rerender(<TripPlannerPage />)
confirmation = [...confirmDialogs].reverse().find(dialog => dialog.isOpen)
const failedConfirm = confirmation?.onConfirm as (() => void | Promise<void>) | undefined
await act(async () => { await failedConfirm?.() })
expect(plannerActions.forgetDeletedTour).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument()
view.rerender(<TripPlannerPage />)
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
it('excludes Tours from the mobile TRIP-PLAN Places pool when the addon is enabled', () => {
const tourPlace = buildPlace({ id: 92, name: 'Mobile ridge', tour_place_id: 92 })
const tourPlaceIds = new Set([tourPlace.id])
renderPage({
isMobile: true,
mobileSidebarOpen: 'right',
toursEnabled: true,
tourPlaceIds,
places: [tourPlace],
})
expect(props('places').toursEnabled).toBe(true)
expect(props('places').excludePlaceIds).toBe(tourPlaceIds)
})
it('passes Tour facet identity to desktop and mobile TRIP-PLAN day cards', () => {
const tourPlaceIds = new Set([92])
const view = renderPage({ activeTab: 'plan', toursEnabled: true, tourPlaceIds })
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds)
hookState = { ...hookState, isMobile: true, mobileSidebarOpen: 'left' }
view.rerender(<TripPlannerPage />)
expect(props('dayPlan').tourPlaceIds).toBe(tourPlaceIds)
})
it('FE-PAGE-TPW-062: opens GPX geometry in the read-only Planner path without mounting the general detail dialog', async () => {
const gpxPlace = buildPlace({ id: 91, name: 'Imported ridge GPX', lat: 47.1, lng: 11.2, route_geometry: '[[47.1,11.2,500],[47.2,11.3,550]]' })
const gpxTour = {
place_id: 91,
name: 'Imported ridge GPX',
tour_type: 'hike',
distance: 8,
elevation_gain: 420,
elevation_loss: 420,
duration: 180,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
planned: false,
caution: false,
has_waypoints: false,
}
const trekTour = { ...gpxTour, place_id: 92, name: 'Editable ridge tour', has_waypoints: true }
const view = renderToursPage({ places: [gpxPlace], tours: [gpxTour, trekTour] })
fireEvent.click(await screen.findByRole('option', { name: /Imported ridge GPX/i }))
expect(plannerActions.viewGpxTour).toHaveBeenCalledWith(gpxTour, gpxPlace.route_geometry)
expect(screen.queryByTestId('tour-detail-dialog')).not.toBeInTheDocument()
const analysis = analyzeRouteGeometry(gpxPlace.route_geometry)
plannerActions.readOnlyGpxTour = { tour: gpxTour, routeGeometry: gpxPlace.route_geometry }
plannerActions.readOnlyGpxAnalysis = analysis
plannerActions.mode = { type: 'view-gpx', placeId: gpxTour.place_id, tour: gpxTour }
view.rerender(<TripPlannerPage />)
expect(props('map').route).toEqual([analysis?.routeCoordinates])
expect(props('map').focusPoints).toEqual(analysis?.routeCoordinates)
expect(props('map').onMapClick).toBeUndefined()
expect(props('map').plannerWaypoints).toEqual([])
expect(screen.getByRole('heading', { name: 'GPX tour' })).toBeInTheDocument()
expect(screen.getAllByText('Imported ridge GPX')).toHaveLength(2)
expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument()
expect(plannerActions.viewGpxTour).toHaveBeenCalledOnce()
})
it('FE-PAGE-TPW-002: the splash holds the page until the trip finished loading', () => {
renderPage({ isLoading: true, splashDone: false })
expect(screen.getByRole('status')).toHaveTextContent('Kyoto')
expect(screen.queryByTestId('map-view')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-003: the splash also covers the gap between load and splashDone', () => {
renderPage({ isLoading: false, splashDone: false })
expect(screen.getByTestId('splash')).toBeInTheDocument()
})
it('FE-PAGE-TPW-004: a trip that never arrived renders nothing at all', () => {
const { container } = renderPage({ trip: null, isLoading: false, splashDone: true })
expect(container).toBeEmptyDOMElement()
})
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / share', () => {
renderPage()
expect(props('navbar').tripTitle).toBe('Kyoto')
act(() => { props('navbar').onBack() })
expect(hookState.navigate).toHaveBeenCalledWith('/dashboard')
act(() => { props('navbar').onShare() })
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true)
})
it('FE-PAGE-TPW-006: the tab bar renders every tab and forwards a switch to the hook', () => {
renderPage()
const tabs = props('tabs').tabs as unknown as Array<{ id: string; title?: string }>
expect(tabs.map(t => t.id)).toContain('collab')
// No native tooltip on any of them: the label is right there, so a title
// would only repeat it under the cursor.
expect(tabs.every(t => t.title === undefined)).toBe(true)
act(() => { props('tabs').onChange('dateien') })
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
})
it('FE-PAGE-TPW-007: a plugin warning chip jumps to that plugin tab', () => {
renderPage()
act(() => { props('warnings').onOpenPluginTab('routes') })
expect(hookState.handleTabChange).toHaveBeenCalledWith('plugin:routes')
})
})
describe('TripPlannerPage — plan tab', () => {
it('FE-PAGE-TPW-008: the map receives the filtered markers, the tile url, the panel widths and the selected day', () => {
renderPage({ transitRoutesShown: true })
expect(props('map').places).toEqual([place])
expect(props('map').tileUrl).toBe('https://tiles/{z}/{x}/{y}.png')
expect(props('map').leftWidth).toBe(320)
expect(props('map').center).toBeUndefined()
// Without the day the route toggle belongs to, the map draws every automated
// transport in the trip as soon as any day's route is on (#2019).
expect(props('map').days).toEqual([day])
expect(props('map').selectedDayId).toBe(7)
// Transit visibility rides the hook's day-aware derivation, not the raw
// toggle — routeShown is still false in this fixture (#2019).
expect(props('map').showTransitRoutes).toBe(true)
})
it('FE-PAGE-TPW-009: collapsed panels report a zero width to the map', () => {
renderPage({ leftHidden: true, rightHidden: true })
expect(props('map').leftWidth).toBe(0)
expect(props('map').rightWidth).toBe(0)
})
it('FE-PAGE-TPW-010: clicking a booking route on the map opens its booking detail', () => {
const reservation = buildReservation({ id: 3, type: 'train' })
renderPage({ reservations: [reservation] })
act(() => { props('map').onReservationClick(3) })
expect(hookState.openBookingDetail).toHaveBeenCalledWith(reservation)
expect(hookState.setMapTransportDetail).not.toHaveBeenCalled()
act(() => { props('map').onReservationClick(999) })
expect(hookState.openBookingDetail).toHaveBeenCalledTimes(1)
})
it("FE-PAGE-TPW-065: the narrow layout keeps sending a map booking to the day list's transport view", () => {
const reservation = buildReservation({ id: 3, type: 'train' })
renderPage({ reservations: [reservation], isMobile: true })
act(() => { props('map').onReservationClick(3) })
expect(hookState.setMapTransportDetail).toHaveBeenCalledWith(reservation)
expect(hookState.openBookingDetail).not.toHaveBeenCalled()
act(() => { props('map').onReservationClick(999) })
expect(hookState.setMapTransportDetail).toHaveBeenCalledTimes(1)
})
it('FE-PAGE-TPW-011: the compass pill appears once the GL map reports itself ready', async () => {
renderPage()
expect(screen.queryByTestId('compass-pill')).not.toBeInTheDocument()
await act(async () => { props('map').onMapReady({ getBearing: () => 0 }) })
await waitFor(() => expect(screen.getAllByTestId('compass-pill').length).toBeGreaterThan(0))
})
it('FE-PAGE-TPW-012: turning the POI pill off in settings removes both POI controls', () => {
setSettings({ map_poi_pill_enabled: false })
renderPage()
expect(screen.queryByTestId('poi-pill')).not.toBeInTheDocument()
expect(screen.queryByTestId('mobile-poi-category-pill')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-013: the collapse buttons toggle each side panel', () => {
renderPage()
// By name, not by position: the map layer carries controls of its own, so the
// tabs are not simply the first two buttons on the page.
const [leftBtn, rightBtn] = screen.getAllByLabelText('common.collapse')
fireEvent.click(leftBtn)
expect(hookState.toggleLeft).toHaveBeenCalled()
fireEvent.mouseEnter(leftBtn)
fireEvent.mouseLeave(leftBtn)
fireEvent.click(rightBtn)
expect(hookState.toggleRight).toHaveBeenCalled()
fireEvent.mouseEnter(rightBtn)
fireEvent.mouseLeave(rightBtn)
})
// The tabs are the only way back to a panel on a touch device, and they used to
// announce themselves with nothing but a hover colour (#2247).
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
renderPage({ leftHidden: true, rightHidden: false })
expect(screen.getByLabelText('trip.mobilePlan')).toBeInTheDocument()
expect(screen.getByLabelText('common.collapse')).toBeInTheDocument()
})
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
// floating POI/compass cluster covered both tabs and the Add Place button (#2247).
it('FE-PAGE-TPW-013c: the floating map controls centre on the map corridor, not the viewport', () => {
const { container } = renderPage({ leftWidth: 340, rightWidth: 300 })
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 310px))')
})
// Plugins can add POI categories (#1781), so the pill may outgrow the corridor; bounded
// to it, the pill scrolls there instead of running on under a panel.
it('FE-PAGE-TPW-063: the floating map controls are no wider than the corridor', () => {
const { container } = renderPage({ leftWidth: 340, rightWidth: 300 })
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
// The two panel insets (350 and 310) and a 12px margin on either side.
expect(cluster.style.maxWidth).toBe('calc(100% - 684px)')
})
it('FE-PAGE-TPW-064: both pill mounts offer the categories the explore hook merged', () => {
poiPillMounts.length = 0
renderPage()
// The floating desktop pill and the portal the narrow layout uses.
expect(poiPillMounts.length).toBeGreaterThanOrEqual(2)
expect(poiPillMounts.every(mount => mount.categories === poiCategories)).toBe(true)
expect(screen.getByTestId('mobile-poi-category-pill')).toContainElement(screen.getAllByTestId('poi-pill')[1])
})
it('FE-PAGE-TPW-013d: a hidden panel takes no corridor away from the map controls', () => {
const { container } = renderPage({ leftWidth: 340, rightWidth: 300, rightHidden: true })
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
})
// Leaflet's base-layer switcher owns the bottom-LEFT corner at z-index 1000, so an
// overview control placed there is simply covered by it (#1736).
it('FE-PAGE-TPW-013d: the trip-overview control hugs the right edge, clear of the layer switcher', () => {
renderPage()
const stack = screen.getByTestId('trip-overview-pill').closest('[style*="position: absolute"]') as HTMLElement
expect(stack).not.toBeNull()
expect(stack.style.right).not.toBe('')
expect(stack.style.left).toBe('')
})
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
renderPage({ leftHidden: true, rightHidden: true })
const [leftBtn, rightBtn] = screen.getAllByRole('button')
fireEvent.mouseEnter(leftBtn)
fireEvent.mouseLeave(leftBtn)
fireEvent.mouseEnter(rightBtn)
fireEvent.mouseLeave(rightBtn)
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument()
})
// The handles take a finger now, with a visible grip on touch screens, so the
// tablet band keeps the one it shows (#1012); before, a 4px mouse-only strip there
// was a trap for a fat finger (#2247).
it('FE-PAGE-TPW-015b: the narrow layout keeps a touch-ready handle on the panel it shows', () => {
const { container } = renderPage({ narrowPanels: true, rightHidden: true })
const handles = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(handles).toHaveLength(1)
expect(handles[0].style.touchAction).toBe('none')
fireEvent.touchStart(handles[0])
expect(hookState.startResizeLeft).toHaveBeenCalled()
})
it('FE-PAGE-TPW-015c: the arrow keys move a handle like a separator (#1012)', () => {
const { container } = renderPage()
const [left, right] = container.querySelectorAll<HTMLElement>('div[role="separator"]')
expect(left).toHaveAttribute('aria-valuenow')
fireEvent.keyDown(left, { key: 'ArrowRight' })
expect(hookState.nudgeLeft).toHaveBeenCalledWith(16)
fireEvent.keyDown(right, { key: 'ArrowLeft' })
expect(hookState.nudgeRight).toHaveBeenCalledWith(16)
fireEvent.keyDown(right, { key: 'ArrowRight' })
expect(hookState.nudgeRight).toHaveBeenCalledWith(-16)
})
it('FE-PAGE-TPW-015: the resize handles start a drag on mouse-down and highlight on hover', () => {
const { container } = renderPage()
const handles = container.querySelectorAll('div[style*="col-resize"]')
expect(handles).toHaveLength(2)
fireEvent.mouseDown(handles[0])
expect(hookState.startResizeLeft).toHaveBeenCalled()
fireEvent.mouseEnter(handles[0])
fireEvent.mouseLeave(handles[0])
fireEvent.mouseDown(handles[1])
expect(hookState.startResizeRight).toHaveBeenCalled()
fireEvent.mouseEnter(handles[1])
fireEvent.mouseLeave(handles[1])
})
})
describe('TripPlannerPage — day plan sidebar wiring', () => {
it('FE-PAGE-TPW-016: a calculated route is pushed into the map, clearing it resets both slots', () => {
renderPage()
act(() => { props('dayPlan').onRouteCalculated({ coordinates: [[1, 2]], distanceText: '1 km' }) })
expect(hookState.setRoute).toHaveBeenCalledWith([[[1, 2]]])
expect(hookState.setRouteInfo).toHaveBeenCalled()
act(() => { props('dayPlan').onRouteCalculated(null) })
expect(hookState.setRoute).toHaveBeenLastCalledWith(null)
expect(hookState.setRouteInfo).toHaveBeenLastCalledWith(null)
})
it('FE-PAGE-TPW-017: adding a booking from a day selects that day and opens the modal', () => {
renderPage()
act(() => { props('dayPlan').onAddReservation(7) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(hookState.setEditingReservation).toHaveBeenCalledWith(null)
expect(tripActions.setSelectedDay).toHaveBeenCalledWith(7)
expect(hookState.setShowReservationModal).toHaveBeenCalledWith(true)
})
it('FE-PAGE-TPW-018: the manual and automated transport entry points differ only in the mode', () => {
renderPage({ trip: { ...trip, start_date: '2025-06-01', end_date: '2025-06-05' } })
act(() => { props('dayPlan').onAddTransport(7) })
expect(hookState.setTransportModalAutomated).toHaveBeenLastCalledWith(false)
act(() => { props('dayPlan').onPlanTransit(7) })
expect(hookState.setTransportModalAutomated).toHaveBeenLastCalledWith(true)
expect(hookState.setTransportModalDayId).toHaveBeenLastCalledWith(7)
})
it('FE-PAGE-TPW-019: a trip without dates hides the transit planner, no rights hides the rest', () => {
renderPage({
trip: { ...trip, start_date: null, end_date: null },
can: vi.fn(() => false),
})
expect(props('dayPlan').onPlanTransit).toBeUndefined()
expect(props('dayPlan').onAddTransport).toBeUndefined()
expect(props('dayPlan').onEditTransport).toBeUndefined()
expect(props('dayPlan').onEditReservation).toBeUndefined()
expect(props('dayPlan').onAddBookingToAssignment).toBeUndefined()
})
it('FE-PAGE-TPW-020: editing a transport or a booking from a day opens the matching modal', () => {
const transport = buildReservation({ id: 4, type: 'train', day_id: 7 })
const booking = buildReservation({ id: 5, type: 'hotel' })
renderPage()
act(() => { props('dayPlan').onEditTransport(transport) })
expect(hookState.setEditingTransport).toHaveBeenCalledWith(transport)
expect(hookState.setTransportModalDayId).toHaveBeenCalledWith(7)
act(() => { props('dayPlan').onEditReservation(booking) })
expect(hookState.setEditingReservation).toHaveBeenCalledWith(booking)
expect(hookState.setShowReservationModal).toHaveBeenCalledWith(true)
})
it('FE-PAGE-TPW-021: the remaining day-plan callbacks reach their hook handlers', () => {
renderPage()
act(() => { props('dayPlan').onOpenTransit(buildReservation({ id: 6, type: 'transit' })) })
expect(hookState.setTransitJourney).toHaveBeenCalled()
act(() => { props('dayPlan').onDayDetail(day) })
expect(hookState.setShowDayDetail).toHaveBeenCalledWith(day)
expect(hookState.selectAssignment).toHaveBeenCalledWith(null)
act(() => { props('dayPlan').onEditPlace(place, 10) })
expect(hookState.setEditingAssignmentId).toHaveBeenCalledWith(10)
act(() => { props('dayPlan').onEditPlace(place, undefined) })
expect(hookState.setEditingAssignmentId).toHaveBeenLastCalledWith(null)
act(() => { props('dayPlan').onDeletePlace(1) })
expect(hookState.handleDeletePlace).toHaveBeenCalledWith(1)
act(() => { props('dayPlan').onToggleRoute() })
expect(updaterOf<boolean>(hookState.setRouteShown)(false)).toBe(true)
act(() => { props('dayPlan').onNavigateToFiles() })
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
act(() => { props('dayPlan').onRouteRefresh() })
expect(hookState.updateRouteForDay).toHaveBeenCalledWith(7)
act(() => { props('dayPlan').onAddBookingToAssignment(7, 10) })
expect(hookState.setBookingForAssignmentId).toHaveBeenCalledWith(10)
act(() => { props('dayPlan').onExternalTransportDetailHandled() })
expect(hookState.setMapTransportDetail).toHaveBeenCalledWith(null)
})
it('FE-PAGE-TPW-022: a route refresh without a selected day is skipped', () => {
renderPage({ selectedDayId: null })
act(() => { props('dayPlan').onRouteRefresh() })
expect(hookState.updateRouteForDay).not.toHaveBeenCalled()
})
it('FE-PAGE-TPW-023: the places sidebar add/edit/bulk callbacks reach the hook', () => {
renderPage()
act(() => { props('places').onAddPlace() })
expect(hookState.setShowPlaceForm).toHaveBeenCalledWith(true)
act(() => { props('places').onEditPlace(place) })
expect(hookState.openPlaceEditor).toHaveBeenCalledWith(place)
act(() => { props('places').onDeletePlace(1) })
expect(hookState.handleDeletePlace).toHaveBeenCalledWith(1)
act(() => { props('places').onBulkDeletePlaces([1, 2]) })
expect(hookState.setDeletePlaceIds).toHaveBeenCalledWith([1, 2])
act(() => { props('places').onBulkChangeCategory([1], 4) })
expect(hookState.confirmChangeCategory).toHaveBeenCalledWith([1], 4)
})
})
describe('TripPlannerPage — day detail and inspector', () => {
it('FE-PAGE-TPW-024: the day detail panel picks its coordinates from the day\'s first geo stop', () => {
renderPage({ showDayDetail: day })
expect(props('dayDetail').lat).toBe(34.9)
expect(props('dayDetail').day).toEqual(day)
act(() => { props('dayDetail').onClose() })
expect(hookState.setShowDayDetail).toHaveBeenCalledWith(null)
expect(hookState.handleSelectDay).toHaveBeenCalledWith(null)
act(() => { props('dayDetail').onToggleCollapse() })
expect(updaterOf<boolean>(hookState.setDayDetailCollapsed)(false)).toBe(true)
act(() => { props('dayDetail').onAccommodationChange() })
expect(hookState.loadAccommodations).toHaveBeenCalled()
})
it('FE-PAGE-TPW-025: a day without geo stops gets no weather anchor from other days (#2167)', () => {
renderPage({ showDayDetail: { ...day, id: 99 }, assignments: {} })
expect(props('dayDetail').lat).toBeNull()
expect(props('dayDetail').lng).toBeNull()
})
it('FE-PAGE-TPW-026: the desktop inspector edits, deletes and rates through the hook', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 5 }] })
useTripStore.setState({ assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] } } as never)
renderPage({ selectedPlace: place, selectedAssignmentId: 10 })
// jsdom reports a 1024px viewport, so the inspector still gets the real panel widths.
expect(props('inspector').leftWidth).toBe(320)
act(() => { props('inspector').onEdit() })
expect(hookState.openPlaceEditor).toHaveBeenCalledWith(place, 10)
act(() => { props('inspector').onDelete() })
expect(hookState.handleDeletePlace).toHaveBeenCalledWith(1)
act(() => { props('inspector').onClose() })
expect(hookState.setSelectedPlaceId).toHaveBeenCalledWith(null)
await act(async () => { await props('inspector').onSetParticipants(10, 7, [5]) })
expect(assignmentsApi.setParticipants).toHaveBeenCalledWith(42, 10, [5])
expect(useTripStore.getState().assignments['7'][0].participants).toEqual([{ user_id: 5 }])
await act(async () => { await props('inspector').onUpdatePlace(1, { name: 'X' }) })
await act(async () => { await props('inspector').onUploadImage(1, new File(['x'], 'a.png')) })
await act(async () => { await props('inspector').onRate(1, 4) })
await act(async () => { await props('inspector').onFileUpload(new FormData()) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.addFile).toHaveBeenCalled()
expect(tripActions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'X' })
expect(tripActions.uploadPlaceImage).toHaveBeenCalled()
expect(tripActions.ratePlace).toHaveBeenCalledWith(42, 1, 4)
})
it('FE-PAGE-TPW-027: inspector write failures are surfaced as toasts', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockRejectedValue(new Error('no rights'))
const tripActions = {
...(baseState().tripActions as Record<string, ReturnType<typeof vi.fn>>),
updatePlace: vi.fn(async () => { throw new Error('bad name') }),
ratePlace: vi.fn(async () => { throw new Error('rate limit') }),
}
renderPage({ selectedPlace: place, tripActions })
await act(async () => { await props('inspector').onSetParticipants(10, 7, [5]) })
await act(async () => { await props('inspector').onUpdatePlace(1, {}) })
await act(async () => { await props('inspector').onRate(1, 4) })
const toast = hookState.toast as { error: ReturnType<typeof vi.fn> }
expect(toast.error).toHaveBeenCalledTimes(3)
expect(toast.error).toHaveBeenCalledWith('no rights')
})
it('FE-PAGE-TPW-028: without upload rights the inspector gets no upload handler', () => {
renderPage({ selectedPlace: place, canUploadFiles: false })
expect(props('inspector').onFileUpload).toBeUndefined()
})
it('FE-PAGE-TPW-029: on mobile the inspector moves into a dismissible bottom sheet', async () => {
renderPage({ selectedPlace: place, isMobile: true, selectedAssignmentId: 10 })
expect(screen.getByTestId('place-inspector')).toBeInTheDocument()
expect(props('inspector').leftWidth).toBe(0)
act(() => { props('inspector').onEdit() })
expect(hookState.openPlaceEditor).toHaveBeenCalledWith(place, 10)
expect(hookState.setSelectedPlaceId).toHaveBeenCalledWith(null)
act(() => { props('inspector').onDelete() })
expect(hookState.handleDeletePlace).toHaveBeenCalledWith(1)
await act(async () => { await props('inspector').onUpdatePlace(1, { name: 'Y' }) })
await act(async () => { await props('inspector').onUploadImage(1, new File(['x'], 'a.png')) })
await act(async () => { await props('inspector').onRate(1, 3) })
await act(async () => { await props('inspector').onFileUpload(new FormData()) })
// Tapping the backdrop closes the sheet, tapping the sheet itself does not.
const backdrop = document.body.querySelector('div[style*="z-index: 9999"]') as HTMLElement
fireEvent.click(backdrop.firstElementChild as HTMLElement)
const before = (hookState.setSelectedPlaceId as ReturnType<typeof vi.fn>).mock.calls.length
fireEvent.click(backdrop)
expect((hookState.setSelectedPlaceId as ReturnType<typeof vi.fn>).mock.calls.length).toBe(before + 1)
})
it('FE-PAGE-TPW-029b: the mobile sheet writes participants and toasts its own failures', async () => {
vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 5 }] })
useTripStore.setState({ assignments: { '7': [buildAssignment({ id: 10, day_id: 7, place })] } } as never)
renderPage({ selectedPlace: place, isMobile: true })
await act(async () => { await props('inspector').onSetParticipants(10, 7, [5]) })
expect(useTripStore.getState().assignments['7'][0].participants).toEqual([{ user_id: 5 }])
vi.mocked(assignmentsApi.setParticipants).mockRejectedValue(new Error('no rights'))
const tripActions = {
...(baseState().tripActions as Record<string, ReturnType<typeof vi.fn>>),
updatePlace: vi.fn(async () => { throw new Error('bad name') }),
ratePlace: vi.fn(async () => { throw new Error('rate limit') }),
}
cleanup()
renderPage({ selectedPlace: place, isMobile: true, tripActions })
await act(async () => { await props('inspector').onSetParticipants(10, 7, [5]) })
await act(async () => { await props('inspector').onUpdatePlace(1, {}) })
await act(async () => { await props('inspector').onRate(1, 2) })
const toast = hookState.toast as { error: ReturnType<typeof vi.fn> }
expect(toast.error.mock.calls.map(c => c[0])).toEqual(['no rights', 'bad name', 'rate limit'])
})
})
describe('TripPlannerPage — mobile drawers', () => {
it('FE-PAGE-TPW-030: the mobile Plan/Places buttons open their drawer', () => {
renderPage()
const planBtn = Array.from(document.body.querySelectorAll('button'))
.find(b => b.textContent === 'trip.mobilePlan')!
const placesBtn = Array.from(document.body.querySelectorAll('button'))
.find(b => b.textContent === 'trip.mobilePlaces')!
fireEvent.click(planBtn)
expect(hookState.setMobileSidebarOpen).toHaveBeenCalledWith('left')
fireEvent.click(placesBtn)
expect(hookState.setMobileSidebarOpen).toHaveBeenCalledWith('right')
})
it('FE-PAGE-TPW-031: the left drawer renders the day plan and its mobile-only callbacks', () => {
renderPage({ mobileSidebarOpen: 'left' })
expect(screen.getAllByTestId('day-plan-sidebar')).toHaveLength(2)
act(() => { props('dayPlan').onSelectDay(7) })
expect(hookState.handleSelectDay).toHaveBeenCalledWith(7)
expect(hookState.setMobileSidebarOpen).toHaveBeenCalledWith(null)
act(() => { props('dayPlan').onPlaceClick(1, 10) })
expect(hookState.handlePlaceClick).toHaveBeenCalledWith(1, 10)
act(() => { props('dayPlan').onRouteCalculated({ coordinates: [[1, 2]] }) })
expect(hookState.setRoute).toHaveBeenCalled()
// clearing from the drawer resets both slots, same as the desktop sidebar
act(() => { props('dayPlan').onRouteCalculated(null) })
expect(hookState.setRoute).toHaveBeenLastCalledWith(null)
expect(hookState.setRouteInfo).toHaveBeenLastCalledWith(null)
act(() => { props('dayPlan').onAddReservation(7) })
act(() => { props('dayPlan').onAddTransport(7) })
act(() => { props('dayPlan').onPlanTransit(7) })
act(() => { props('dayPlan').onOpenTransit(buildReservation({ id: 8, type: 'transit' })) })
act(() => { props('dayPlan').onAddPlace() })
act(() => { props('dayPlan').onEditPlace(place, 10) })
act(() => { props('dayPlan').onNavigateToFiles() })
act(() => { props('dayPlan').onEditTransport(buildReservation({ id: 9, type: 'train', day_id: null })) })
act(() => { props('dayPlan').onEditReservation(buildReservation({ id: 11, type: 'hotel' })) })
act(() => { props('dayPlan').onScrollTopChange(120) })
act(() => { props('dayPlan').onDayDetail(day) })
act(() => { props('dayPlan').onDeletePlace(1) })
act(() => { props('dayPlan').onToggleRoute() })
expect((hookState.mobilePlanScrollTopRef as { current: number }).current).toBe(120)
expect(hookState.setTransportModalDayId).toHaveBeenLastCalledWith(null)
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
// The drawer header closes the sheet, and so does its backdrop.
const header = screen.getByText('trip.mobilePlan')
expect(header).toBeInTheDocument()
const closeBtn = header.parentElement!.querySelector('button')!
fireEvent.click(closeBtn)
expect(hookState.setMobileSidebarOpen).toHaveBeenCalledWith(null)
const backdrop = header.closest('div[style*="z-index: 9999"]') as HTMLElement
const closes = (hookState.setMobileSidebarOpen as ReturnType<typeof vi.fn>).mock.calls.length
fireEvent.click(backdrop)
expect((hookState.setMobileSidebarOpen as ReturnType<typeof vi.fn>).mock.calls.length).toBe(closes + 1)
})
it('FE-PAGE-TPW-032: the right drawer renders the places list and its mobile-only callbacks', () => {
renderPage({ mobileSidebarOpen: 'right' })
expect(screen.getAllByTestId('places-sidebar')).toHaveLength(2)
expect(screen.getByText('trip.mobilePlaces')).toBeInTheDocument()
act(() => { props('places').onPlaceClick(1) })
expect(hookState.handlePlaceClick).toHaveBeenCalledWith(1)
act(() => { props('places').onAddPlace() })
act(() => { props('places').onEditPlace(place) })
act(() => { props('places').onDeletePlace(1) })
act(() => { props('places').onBulkDeletePlaces([1]) })
act(() => { props('places').onBulkDeleteConfirm([1]) })
act(() => { props('places').onBulkChangeCategory([1], null) })
act(() => { props('places').onScrollTopChange(40) })
expect(hookState.confirmDeletePlaces).toHaveBeenCalledWith([1])
expect((hookState.mobilePlacesScrollTopRef as { current: number }).current).toBe(40)
})
it('FE-PAGE-TPW-033: an open drawer hides the floating map buttons', () => {
renderPage({ mobileSidebarOpen: 'left' })
expect(screen.queryByTestId('mobile-poi-category-pill')).not.toBeInTheDocument()
expect(Array.from(document.body.querySelectorAll('button'))
.some(b => b.textContent === 'trip.mobilePlaces')).toBe(false)
})
})
describe("TripPlannerPage — the plan's booking detail", () => {
it("FE-PAGE-TPW-066: the desktop day list and the inspector open a booking's detail", () => {
renderPage({ selectedPlace: place })
// The day list's own opener: its rows asked for day_edit before the editor.
expect(props('dayPlan').onOpenBooking).toBe(hookState.openBookingFromDayList)
expect(props('inspector').onOpenBooking).toBe(hookState.openBookingDetail)
// The inspector keeps its editors: the detail's Edit goes on to them.
expect(props('inspector').onEditReservation).toBeTypeOf('function')
})
it("FE-PAGE-TPW-067: the day card opens a booking's detail on the desktop only", () => {
renderPage({ showDayDetail: day })
expect(props('dayDetail').onOpenBooking).toBe(hookState.openBookingDetail)
cleanup()
renderPage({ showDayDetail: day, isMobile: true })
expect(props('dayDetail').onOpenBooking).toBeUndefined()
})
it('FE-PAGE-TPW-068: the narrow layout keeps its day list paths, in the panel and in the drawer', () => {
renderPage({ isMobile: true })
expect(props('dayPlan').onOpenBooking).toBeUndefined()
cleanup()
renderPage({ mobileSidebarOpen: 'left' })
// The drawer's copy renders last; it never gets the detail.
expect(props('dayPlan').onOpenTransit).toBeTypeOf('function')
expect(props('dayPlan').onOpenBooking).toBeUndefined()
})
it('FE-PAGE-TPW-069: nothing is mounted while no booking is open', () => {
renderPage()
expect(screen.queryByTestId('booking-detail')).not.toBeInTheDocument()
})
it("FE-PAGE-TPW-070: the open booking's detail gets its rights and hands every way out to the planner", async () => {
const train = buildReservation({ id: 3, type: 'train', title: 'Shinkansen' })
const editor = vi.fn()
const changeRoute = vi.fn()
renderPage({ bookingDetail: train, bookingDetailEditor: editor, bookingDetailChangeRoute: changeRoute })
expect(screen.getByTestId('booking-detail')).toBeInTheDocument()
const d = props('bookingDetail')
expect(d.r).toBe(train)
expect(d.tripId).toBe(42)
expect(d.canEdit).toBe(true)
expect(d.onEdit).toBe(editor)
expect(d.onChangeRoute).toBe(changeRoute)
expect(d.onClose).toBe(hookState.closeBookingDetail)
expect(d.onDelete).toBe(hookState.handleDeleteReservation)
expect(d.onShowOnMap).toBe(hookState.showBookingOnMap)
expect(d.isOnMap).toBe(hookState.isBookingOnMap)
act(() => { d.onNavigateToFiles() })
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
act(() => { d.onEditExpense({ id: 12, name: 'Ticket' }) })
await waitFor(() => expect(screen.getByTestId('expense-modal')).toBeInTheDocument())
expect(props('expenseModal').editing).toEqual({ id: 12, name: 'Ticket' })
})
it('FE-PAGE-TPW-071: without the booking right the detail stays read-only', () => {
const can = vi.fn((action: string) => action !== 'reservation_edit')
renderPage({ bookingDetail: buildReservation({ id: 3 }), can })
expect(props('bookingDetail').canEdit).toBe(false)
})
})
describe('TripPlannerPage — other tabs', () => {
const flight = buildReservation({ id: 1, type: 'flight' })
const hotel = buildReservation({ id: 2, type: 'hotel' })
const transit = buildReservation({ id: 3, type: 'train' })
it('FE-PAGE-TPW-034: the transports tab lists only transport bookings and wires its actions', async () => {
const dinner = buildReservation({ id: 4, type: 'restaurant' })
renderPage({ activeTab: 'transports', reservations: [flight, hotel, transit, dinner] })
// The panel loads on demand now; its props only exist once the chunk is in.
await screen.findByTestId('reservations-panel')
const listed = props('reservationsPanel').reservations as unknown as Reservation[]
expect(listed.map(r => r.id)).toEqual([1, 3])
// The timeline's "other tab" lane gets every booking of the Book tab, not only the stays.
const context = props('reservationsPanel').contextReservations as unknown as Reservation[]
expect(context.map(r => r.id)).toEqual([2, 4])
act(() => { props('reservationsPanel').onAdd() })
expect(hookState.setTransportModalAutomated).toHaveBeenCalledWith(false)
act(() => { props('reservationsPanel').onImport() })
expect(hookState.setShowBookingImport).toHaveBeenCalledWith(true)
act(() => { props('reservationsPanel').onAirTrailImport() })
expect(hookState.setShowAirTrailImport).toHaveBeenCalledWith(true)
act(() => { props('reservationsPanel').onEdit(transit) })
expect(hookState.openTransportEditor).toHaveBeenCalledWith(transit)
act(() => { props('reservationsPanel').onDelete(1) })
expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(1)
act(() => { props('reservationsPanel').onNavigateToFiles() })
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
})
it("FE-PAGE-TPW-035: the Transports tab edits a transit journey in the full editor and changes its route under the plan's right", async () => {
const journey = buildReservation({ id: 9, type: 'transit' })
const changeRoute = vi.fn()
renderPage({ activeTab: 'transports', reservations: [journey], bookingDetailChangeRoute: changeRoute })
await screen.findByTestId('reservations-panel')
act(() => { props('reservationsPanel').onEdit(journey) })
expect(hookState.openTransportEditor).toHaveBeenCalledWith(journey)
expect(hookState.setTransitJourney).not.toHaveBeenCalled()
// The same day_edit-gated handler the plan's detail gets.
expect(props('reservationsPanel').onChangeRoute).toBe(changeRoute)
cleanup()
// Without day_edit the hook hands over none, and the tab offers none.
renderPage({ activeTab: 'transports', reservations: [journey], bookingDetailChangeRoute: undefined })
await screen.findByTestId('reservations-panel')
expect(props('reservationsPanel').onChangeRoute).toBeUndefined()
})
it('FE-PAGE-TPW-072: both booking tabs show a booking on the map the way the plan does', async () => {
for (const activeTab of ['transports', 'buchungen']) {
renderPage({ activeTab, reservations: [flight, hotel] })
await screen.findByTestId('reservations-panel')
expect(props('reservationsPanel').onShowOnMap).toBe(hookState.showBookingOnMap)
expect(props('reservationsPanel').isOnMap).toBe(hookState.isBookingOnMap)
cleanup()
}
})
it('FE-PAGE-TPW-036: the bookings tab lists everything that is not transport', async () => {
renderPage({ activeTab: 'buchungen', reservations: [flight, hotel] })
await screen.findByTestId('reservations-panel')
const listed = props('reservationsPanel').reservations as unknown as Reservation[]
expect(listed.map(r => r.id)).toEqual([2])
act(() => { props('reservationsPanel').onAdd() })
expect(hookState.setShowReservationModal).toHaveBeenCalledWith(true)
act(() => { props('reservationsPanel').onImport() })
expect(hookState.setShowBookingImport).toHaveBeenCalledWith(true)
act(() => { props('reservationsPanel').onEdit(hotel) })
expect(hookState.setEditingReservation).toHaveBeenCalledWith(hotel)
act(() => { props('reservationsPanel').onNavigateToFiles() })
expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien')
})
it('FE-PAGE-TPW-037: the costs, collab and plugin tabs mount their panel', async () => {
renderPage({ activeTab: 'finanzplan' })
expect(await screen.findByTestId('costs-panel')).toBeInTheDocument()
cleanup()
renderPage({ activeTab: 'collab' })
expect(await screen.findByTestId('collab-panel')).toBeInTheDocument()
cleanup()
// PluginFrame is deliberately not lazy — DayDetailPanel and PlaceInspector
// pull it in from the plan tab anyway — so this stays synchronous.
renderPage({ activeTab: 'plugin:routes' })
expect(props('pluginFrame').pluginId).toBe('routes')
expect(props('pluginFrame').tripId).toBe('42')
})
it('FE-PAGE-TPW-038: the files tab hands the manager the trip files and the write callbacks', async () => {
renderPage({ activeTab: 'dateien', files: [] })
await screen.findByTestId('file-manager')
expect(props('fileManager').allowedFileTypes).toBe('pdf')
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
await act(async () => { await props('fileManager').onUpload(new FormData()) })
await act(async () => { await props('fileManager').onDelete(3) })
act(() => { props('fileManager').onUpdate(3, {}) })
expect(tripActions.addFile).toHaveBeenCalled()
expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 3)
expect(tripActions.loadFiles).toHaveBeenCalledWith(42)
})
})
describe('TripPlannerPage — lists tab', () => {
it('FE-PAGE-TPW-039: the lists tab starts on packing and remembers the chosen subtab', async () => {
renderPage({ activeTab: 'listen', packingItems: [buildPackingItem({ checked: 0 })], todoItems: [buildTodoItem()] })
expect(await screen.findByTestId('packing-list-panel')).toBeInTheDocument()
fireEvent.click(screen.getByText('To-Do'))
expect(await screen.findByTestId('todo-list-panel')).toBeInTheDocument()
expect(sessionStorage.getItem('trip-lists-subtab-42')).toBe('todo')
})
it('FE-PAGE-TPW-040: a persisted subtab wins over the packing default', async () => {
sessionStorage.setItem('trip-lists-subtab-42', 'todo')
renderPage({ activeTab: 'listen' })
expect(await screen.findByTestId('todo-list-panel')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Add new task/i }))
expect(props('todoPanel').addItemSignal).toBe(1)
})
it('FE-PAGE-TPW-041: Add list in the bar opens the list name field in the panel', async () => {
renderPage({ activeTab: 'listen', packingItems: [buildPackingItem({ checked: 1 })] })
await screen.findByTestId('packing-list-panel')
// Removing checked items moved into the panel's progress card.
expect(screen.queryByRole('button', { name: /Remove 1 checked/i })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Add list/i }))
expect(props('packingPanel').addCategorySignal).toBe(1)
})
it('FE-PAGE-TPW-042: an admin can save the current list as a template', async () => {
seedStore(useAuthStore, { user: buildUser({ id: 5, role: 'admin' }) })
renderPage({ activeTab: 'listen', packingItems: [buildPackingItem({ checked: 0 })] })
await screen.findByTestId('packing-list-panel')
fireEvent.click(screen.getByRole('button', { name: /Save as template/i }))
expect(props('packingPanel').saveTemplateSignal).toBe(1)
})
it('FE-PAGE-TPW-043: a non-admin sees import but no save-as-template', async () => {
renderPage({ activeTab: 'listen', packingItems: [buildPackingItem({ checked: 0 })] })
await screen.findByTestId('packing-list-panel')
expect(screen.queryByRole('button', { name: /Save as template/i })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Import/i }))
expect(props('packingPanel').openImportSignal).toBe(1)
})
it('FE-PAGE-TPW-044: switching the packing visibility is forwarded to the template button', async () => {
renderPage({ activeTab: 'listen', packingItems: [] })
await screen.findByTestId('packing-list-panel')
expect(props('applyTemplate').visibility).toBe('common')
act(() => { props('packingPanel').onViewChange('personal') })
expect(props('applyTemplate').visibility).toBe('personal')
})
it('FE-PAGE-TPW-044b: export and import sit in the header as icons, export following the open view (#875, #1420)', async () => {
renderPage({ activeTab: 'listen', packingItems: [buildPackingItem({ checked: 0 })] })
await screen.findByTestId('packing-list-panel')
expect(screen.getByTestId('export-menu')).toBeInTheDocument()
expect(props('exportMenu').tripId).toBe(42)
expect(props('exportMenu').view).toBe('common')
act(() => { props('packingPanel').onViewChange('personal') })
expect(props('exportMenu').view).toBe('personal')
const importButton = screen.getByRole('button', { name: 'Import' })
expect(importButton).toHaveAttribute('title', 'Import')
expect(importButton).toHaveTextContent('')
})
})
describe('TripPlannerPage — modals', () => {
it('FE-PAGE-TPW-045: closing the place form clears every editing slot', () => {
renderPage()
act(() => { props('placeForm').onClose() })
expect(hookState.setShowPlaceForm).toHaveBeenCalledWith(false)
expect(hookState.setEditingPlace).toHaveBeenCalledWith(null)
expect(hookState.setPrefillCoords).toHaveBeenCalledWith(null)
// The road trip's manual add is one of the ways this form opens; closing it has to
// put the form back to the one every other caller gets.
expect(hookState.setServiceStopForm).toHaveBeenCalledWith(false)
act(() => { props('placeForm').onCategoryCreated({ id: 3, name: 'Food' }) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.addCategory).toHaveBeenCalledWith({ id: 3, name: 'Food' })
})
it('FE-PAGE-TPW-046: the place form only offers day assignments while editing', () => {
renderPage()
expect(props('placeForm').dayAssignments).toEqual([])
cleanup()
renderPage({ editingPlace: place })
expect((props('placeForm').dayAssignments as unknown as unknown[]).length).toBe(1)
})
it('FE-PAGE-TPW-047: saving the trip form reloads the accommodations and confirms', async () => {
renderPage()
await act(async () => { await props('tripForm').onSave({ title: 'Kyoto 2' }) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.updateTrip).toHaveBeenCalledWith(42, { title: 'Kyoto 2' })
expect(hookState.loadAccommodations).toHaveBeenCalled()
act(() => { props('tripForm').onClose() })
expect(hookState.setShowTripForm).toHaveBeenCalledWith(false)
})
it('FE-PAGE-TPW-048: a new cover image is written straight into the trip store', () => {
useTripStore.setState({ trip: buildTrip({ id: 42 }) } as never)
renderPage()
act(() => { props('tripForm').onCoverUpdate(42, '/uploads/covers/new.jpg') })
expect(useTripStore.getState().trip?.cover_image).toBe('/uploads/covers/new.jpg')
useTripStore.setState({ trip: null } as never)
act(() => { props('tripForm').onCoverUpdate(42, '/uploads/covers/other.jpg') })
expect(useTripStore.getState().trip).toBeNull()
})
it('FE-PAGE-TPW-049: the members modal refreshes the roster when it changes', () => {
renderPage()
act(() => { props('membersModal').onMembersChanged() })
expect(hookState.refreshMembers).toHaveBeenCalled()
act(() => { props('membersModal').onClose() })
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(false)
})
it('FE-PAGE-TPW-050: closing the booking modal outside a review resets the editor', async () => {
renderPage()
act(() => { props('reservationModal').onClose() })
expect(hookState.setShowReservationModal).toHaveBeenCalledWith(false)
expect(hookState.setBookingForAssignmentId).toHaveBeenCalledWith(null)
expect(hookState.advanceImportReview).not.toHaveBeenCalled()
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
await act(async () => { await props('reservationModal').onFileUpload(new FormData()) })
act(() => { props('reservationModal').onFileDelete(8) })
expect(tripActions.addFile).toHaveBeenCalled()
expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 8)
})
it('FE-PAGE-TPW-051: during an import review both booking modal exits advance the queue', async () => {
renderPage({ importReviewActive: true })
act(() => { props('reservationModal').onClose() })
expect(hookState.advanceImportReview).toHaveBeenCalledTimes(1)
await act(async () => { await props('reservationModal').onSave({ title: 'Ryokan' }) })
expect(hookState.handleSaveReservation).toHaveBeenCalledWith({ title: 'Ryokan' })
expect(hookState.advanceImportReview).toHaveBeenCalledTimes(2)
})
it('FE-PAGE-TPW-052: a booking save that returned nothing does not advance the review', async () => {
renderPage({ importReviewActive: true, handleSaveReservation: vi.fn(async () => undefined) })
await act(async () => { await props('reservationModal').onSave({ title: 'x' }) })
expect(hookState.advanceImportReview).not.toHaveBeenCalled()
})
it('FE-PAGE-TPW-053: the transport modal only mounts while it is open', async () => {
renderPage()
expect(screen.queryByTestId('transport-modal')).not.toBeInTheDocument()
cleanup()
renderPage({ showTransportModal: true })
// Loads on demand now — it is the only path to TransitSearchPanel, which
// carries tz-lookup.
expect(await screen.findByTestId('transport-modal')).toBeInTheDocument()
await act(async () => { await props('transportModal').onSave({ title: 'ICE' }) })
expect(hookState.handleSaveTransport).toHaveBeenCalledWith({ title: 'ICE' })
act(() => { props('transportModal').onClose() })
expect(hookState.setShowTransportModal).toHaveBeenCalledWith(false)
expect(hookState.setTransitPrefill).toHaveBeenCalledWith(null)
await act(async () => { await props('transportModal').onFileUpload(new FormData()) })
act(() => { props('transportModal').onFileDelete(2) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.deleteFile).toHaveBeenCalledWith(42, 2)
})
it('FE-PAGE-TPW-053b: the transport edit window deletes the booking when the user may edit reservations', async () => {
const res = buildReservation({ id: 55, type: 'flight', title: 'LH 400' })
renderPage({ showTransportModal: true, editingTransport: res })
await act(async () => { await props('transportModal').onDelete() })
expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(55)
})
it('FE-PAGE-TPW-053c: no transport delete without an edited booking or permission', () => {
renderPage({ showTransportModal: true })
expect(props('transportModal').onDelete).toBeUndefined()
cleanup()
renderPage({
showTransportModal: true,
editingTransport: buildReservation({ id: 55, type: 'flight' }),
can: vi.fn((action: string) => action !== 'reservation_edit'),
})
expect(props('transportModal').onDelete).toBeUndefined()
})
it('FE-PAGE-TPW-054: during a review the transport modal exits advance the queue too', async () => {
renderPage({ showTransportModal: true, importReviewActive: true })
act(() => { props('transportModal').onClose() })
await act(async () => { await props('transportModal').onSave({ title: 'ICE' }) })
expect(hookState.advanceImportReview).toHaveBeenCalledTimes(2)
})
it('FE-PAGE-TPW-055: the transit journey view saves, deletes and re-enters the search', async () => {
const journey = buildReservation({
id: 9, type: 'transit', day_id: 7,
endpoints: [
{ role: 'from', name: 'Kyoto', lat: 34.9, lng: 135.7 },
{ role: 'to', name: 'Osaka', lat: 34.7, lng: 135.5 },
] as never,
})
renderPage({ transitJourney: journey, reservations: [journey] })
expect(props('transitModal').canEdit).toBe(true)
await act(async () => { await props('transitModal').onSave({ title: 'Updated' }) })
const tripActions = hookState.tripActions as Record<string, ReturnType<typeof vi.fn>>
expect(tripActions.updateReservation).toHaveBeenCalledWith(42, 9, { title: 'Updated' })
expect(hookState.setTransitJourney).toHaveBeenCalledWith(null)
await act(async () => { await props('transitModal').onDelete() })
expect(hookState.handleDeleteReservation).toHaveBeenCalledWith(9)
act(() => { props('transitModal').onChangeRoute() })
expect(hookState.changeTransitRoute).toHaveBeenCalledWith(journey)
act(() => { props('transitModal').onClose() })
expect(hookState.setTransitJourney).toHaveBeenLastCalledWith(null)
})
it('FE-PAGE-TPW-061: edit details hands the fresh reservation to the full transport editor', () => {
const stale = buildReservation({ id: 9, type: 'transit', day_id: 7 })
// The store copy may be newer than the journey held in state — the fresh
// one must win.
const fresh = { ...stale, title: 'Kyoto → Osaka' }
renderPage({ transitJourney: stale, reservations: [fresh] })
act(() => { props('transitModal').onEditDetails() })
expect(hookState.openTransportEditor).toHaveBeenCalledWith(fresh)
})
it('FE-PAGE-TPW-057: a booking opens the expense editor, prefilled or on an existing item', async () => {
setSettings({ default_currency: 'eur' })
renderPage()
act(() => { props('reservationModal').onOpenExpense({ prefill: { amount: 20 } }) })
await waitFor(() => expect(screen.getByTestId('expense-modal')).toBeInTheDocument())
expect(props('expenseModal').base).toBe('EUR')
expect(props('expenseModal').me).toBe(5)
expect(props('expenseModal').prefill).toEqual({ amount: 20 })
act(() => { props('expenseModal').onSaved() })
await waitFor(() => expect(screen.queryByTestId('expense-modal')).not.toBeInTheDocument())
expect(useTripStore.getState().loadBudgetItems).toHaveBeenCalledWith(42)
act(() => { props('reservationModal').onOpenExpense({ editItem: { id: 4 } }) })
await waitFor(() => expect(props('expenseModal').editing).toEqual({ id: 4 }))
act(() => { props('expenseModal').onClose() })
await waitFor(() => expect(screen.queryByTestId('expense-modal')).not.toBeInTheDocument())
})
it('FE-PAGE-TPW-058: an expense request with neither item nor prefill opens nothing', () => {
renderPage()
act(() => { props('reservationModal').onOpenExpense({}) })
expect(screen.queryByTestId('expense-modal')).not.toBeInTheDocument()
})
it('FE-PAGE-TPW-059: the costs base currency falls back from the account to the trip', async () => {
setSettings({ default_currency: '' })
renderPage()
act(() => { props('reservationModal').onOpenExpense({ prefill: { amount: 1 } }) })
await waitFor(() => expect(props('expenseModal').base).toBe('JPY'))
})
it('FE-PAGE-TPW-060: both import modals and both delete dialogs close through the hook', () => {
renderPage({ deletePlaceId: 1, deletePlaceIds: [1, 2] })
act(() => { props('bookingImport').onClose() })
expect(hookState.setShowBookingImport).toHaveBeenCalledWith(false)
act(() => { props('airtrailImport').onClose() })
expect(hookState.setShowAirTrailImport).toHaveBeenCalledWith(false)
const [single, bulk] = confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>
expect(single.isOpen).toBe(true)
expect(bulk.isOpen).toBe(true)
act(() => { single.onClose() })
act(() => { single.onConfirm() })
expect(hookState.setDeletePlaceId).toHaveBeenCalledWith(null)
expect(hookState.confirmDeletePlace).toHaveBeenCalled()
act(() => { bulk.onClose() })
act(() => { bulk.onConfirm() })
expect(hookState.setDeletePlaceIds).toHaveBeenCalledWith(null)
expect(hookState.confirmDeletePlaces).toHaveBeenCalled()
})
it('FE-PAGE-TPW-062: the question before a booked night becomes a pause answers through the hook', () => {
renderPage({ stayRelease: { stop: { stopType: null, dwellMinutes: 30 }, name: 'Hotel Fjord', booking: 'Booking 4711' } })
// Found by its handler rather than its place: the delete-day question sits in front of it now.
const release = (confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>)
.find(dialog => dialog.onConfirm === hookState.confirmStayRelease)!
expect(release.isOpen).toBe(true)
act(() => { release.onClose() })
expect(hookState.setStayRelease).toHaveBeenCalledWith(null)
act(() => { release.onConfirm() })
expect(hookState.confirmStayRelease).toHaveBeenCalled()
})
it('FE-PAGE-TPW-073: adding a stay from a day opens its panel with the stay picker, which reports back once open', () => {
const setStayPickerDayId = vi.fn()
renderPage({ stayPickerDayId: null, setStayPickerDayId })
act(() => { props('dayPlan').onAddAccommodation(day) })
expect(hookState.handleSelectDay).toHaveBeenCalledWith(7)
expect(hookState.setShowDayDetail).toHaveBeenCalledWith(day)
expect(hookState.selectAssignment).toHaveBeenCalledWith(null)
expect(setStayPickerDayId).toHaveBeenCalledWith(7)
cleanup()
renderPage({ showDayDetail: day, stayPickerDayId: 7, setStayPickerDayId })
expect(props('dayDetail').openStayPicker).toBe(true)
act(() => { props('dayDetail').onStayPickerOpened() })
expect(setStayPickerDayId).toHaveBeenLastCalledWith(null)
})
it('FE-PAGE-TPW-074: a linked expense in either booking tab opens the expense editor on that item', async () => {
for (const activeTab of ['transports', 'buchungen']) {
renderPage({ activeTab })
await screen.findByTestId('reservations-panel')
act(() => { props('reservationsPanel').onEditExpense({ id: 12 }) })
await waitFor(() => expect(props('expenseModal').editing).toEqual({ id: 12 }))
cleanup()
}
})
})