mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
a7f5a114e6
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.
1777 lines
80 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|