Files
TREK/client/tests/unit/mobile/trip/MTripShell.test.tsx
T
Maurice b267771772 fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and
it was the only way to reduce a busy map to one day. The phone shell does not
render DayPlanSidebar, so when it replaced the desktop page on phones that
control disappeared with it: expandedDayIds stayed null and every day's pins sat
on top of each other with nothing to do about it (#1962).

A day chip in map view now drives the same declutter the desktop chevron drives,
rather than introducing a second filter. Entering the map focuses the selected
day, tapping another chip moves the focus with it, and leaving the map clears it
so the timeline is never looking at a filtered state. Unplanned places stay
visible, exactly as the collapse path has always left them.
2026-08-18 17:40:56 +02:00

452 lines
21 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
// FE-MOB-SHELL-001 to FE-MOB-SHELL-034
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
vi.mock('../../../../src/pages/tripPlanner/useTripPlanner', () => ({
useTripPlanner: () => mocks.planner,
}))
// The real slots pull in the map engine and every tab panel; the shell only
// ever hands them props, so they are replaced by the probes below.
vi.mock('../../../../src/mobile/screens/trip/plan/MPlanTimeline', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/map/MMapArea', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/places/MPlacesBrowser', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/tabs/MTripTabPanel', () => ({ default: () => null }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MTripSheets', () => ({ default: () => null }))
import MTripShell from '../../../../src/mobile/screens/trip/MTripShell'
/** Last shell api handed to a slot — the object every slot shares. */
let shellApi!: MTripShellApi
function slot(testId: string) {
return function Slot({ shell }: { planner: TripPlanner; shell: MTripShellApi }) {
shellApi = shell
return <div data-testid={testId} />
}
}
function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) {
shellApi = shell
return <div data-testid="tab-panel">{tab}</div>
}
const TRIP_TABS = [
{ id: 'plan', label: 'Plan' },
{ id: 'transports', label: 'Transport' },
{ id: 'buchungen', label: 'Bookings' },
{ id: 'finanzplan', label: 'Budget' },
{ id: 'listen', label: 'Lists' },
{ id: 'dateien', label: 'Files' },
{ id: 'collab', label: 'Collaboration' },
]
const DAYS = [
{ id: 11, trip_id: 1, day_number: 1, date: '2026-05-02', title: null },
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null },
] as unknown as Day[]
function todayIso(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
function renderShell(overrides: Partial<TripPlanner> = {}) {
mocks.planner = buildPlanner({
days: DAYS,
selectedDayId: 11,
TRIP_TABS,
...overrides,
} as Partial<TripPlanner>)
const view = render(
<MTripShell
PlanTimeline={slot('plan-timeline')}
MapArea={slot('map-area')}
PlacesBrowser={slot('places-browser')}
TabPanel={TabSlot}
Sheets={slot('sheets')}
/>,
)
return { ...view, planner: mocks.planner }
}
const spy = (planner: TripPlanner, name: keyof TripPlanner) =>
vi.mocked(planner[name] as unknown as ReturnType<typeof vi.fn>)
describe('MTripShell', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => {
renderShell({ isLoading: true } as Partial<TripPlanner>)
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
expect(screen.queryByTestId('sheets')).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-002: keeps the splash up until the photo warm-up finished', () => {
renderShell({ splashDone: false } as Partial<TripPlanner>)
expect(screen.getByText('Japan 2026')).toBeInTheDocument()
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-003: renders nothing once loading finished without a trip', () => {
const { container } = renderShell({ trip: null } as Partial<TripPlanner>)
expect(container).toBeEmptyDOMElement()
})
it('FE-MOB-SHELL-003b: the splash falls back to the brand name before the trip arrives', () => {
renderShell({ isLoading: true, trip: null } as Partial<TripPlanner>)
expect(screen.getByText('TREK')).toBeInTheDocument()
})
it('FE-MOB-SHELL-004: seeds the running trip on today rather than on day one', () => {
const days = [
{ id: 11, day_number: 1, date: '2026-05-02' },
{ id: 12, day_number: 2, date: todayIso() },
] as unknown as Day[]
const { planner } = renderShell({ days, selectedDayId: null } as Partial<TripPlanner>)
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12)
})
it('FE-MOB-SHELL-005: falls back to the first day when the trip is not running', () => {
const { planner } = renderShell({ selectedDayId: null } as Partial<TripPlanner>)
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11)
})
it('FE-MOB-SHELL-006: leaves an existing day selection alone', () => {
const { planner } = renderShell()
expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => {
const { planner } = renderShell({ days: [], selectedDayId: null } as Partial<TripPlanner>)
expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Sat 2' })).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-008: the back button leaves for the dashboard', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'common.back' }))
expect(planner.navigate).toHaveBeenCalledWith('/dashboard')
})
it('FE-MOB-SHELL-009: the plan tab mounts the map under the timeline and no browser', () => {
renderShell()
expect(screen.getByTestId('map-area')).toBeInTheDocument()
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
expect(screen.getByTestId('sheets')).toBeInTheDocument()
expect(screen.queryByTestId('places-browser')).not.toBeInTheDocument()
expect(screen.queryByTestId('tab-panel')).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-010: the browse segment swaps the timeline for the places browser', () => {
renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
expect(screen.getByTestId('places-browser')).toBeInTheDocument()
expect(screen.queryByTestId('plan-timeline')).not.toBeInTheDocument()
expect(shellApi.mode).toBe('browse')
expect(shellApi.view).toBe('plan')
})
it('FE-MOB-SHELL-011: browse entered from edit flags browseFromEdit for the pool filter', () => {
renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlan' }))
expect(shellApi.mode).toBe('edit')
expect(shellApi.browseFromEdit).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
expect(shellApi.browseFromEdit).toBe(true)
})
it('FE-MOB-SHELL-012: browse entered from travel leaves browseFromEdit off', () => {
renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
expect(shellApi.browseFromEdit).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.travel' }))
expect(shellApi.mode).toBe('go')
})
it('FE-MOB-SHELL-013: switching the travel mode from another tab forces the plan tab', () => {
const { planner } = renderShell({ activeTab: 'listen' } as Partial<TripPlanner>)
act(() => { shellApi.setTravelMode('edit') })
expect(planner.handleTabChange).toHaveBeenCalledWith('plan')
expect(shellApi.mode).toBe('edit')
})
it('FE-MOB-SHELL-014: the map toggle draws the route and refits the selected day', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
expect(planner.handleSelectDay).toHaveBeenCalledWith(11, false)
expect(shellApi.view).toBe('map')
// The timeline unmounts, the map layer stays warm underneath.
expect(screen.queryByTestId('plan-timeline')).not.toBeInTheDocument()
expect(screen.getByTestId('map-area')).toBeInTheDocument()
})
it('FE-MOB-SHELL-015: leaving the map restores the timeline without touching the route', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
expect(shellApi.view).toBe('plan')
expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
expect(spy(planner, 'setRouteShown')).toHaveBeenCalledTimes(1)
})
it('FE-MOB-SHELL-016: the map toggle skips the refit while no day is selected', () => {
const { planner } = renderShell({ selectedDayId: null } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-017: the map toggle drops out of browse mode', () => {
renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(shellApi.mode).toBe('go')
expect(screen.queryByTestId('places-browser')).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-018: day chips render weekday + date and mark the active one', () => {
renderShell()
const chip = screen.getByRole('button', { name: 'Sat 2' })
expect(chip).toHaveAttribute('aria-current', 'true')
expect(screen.getByRole('button', { name: 'Sun 3' })).not.toHaveAttribute('aria-current')
})
it('FE-MOB-SHELL-019: a chip without a usable date falls back to the day number', () => {
const days = [
{ id: 11, day_number: 1, date: null },
{ id: 12, day_number: 2, date: 'nodatehere' },
{ id: 13, date: null },
] as unknown as Day[]
renderShell({ days } as Partial<TripPlanner>)
expect(screen.getByRole('button', { name: 'planner.dayN:1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'planner.dayN:2' })).toBeInTheDocument()
// A day the server sent without a number falls back to its position.
expect(screen.getByRole('button', { name: 'planner.dayN:3' })).toBeInTheDocument()
})
it('FE-MOB-SHELL-020: tapping another day selects it and fits the list view', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.handleSelectDay).toHaveBeenCalledWith(12, true)
expect(planner.setRouteShown).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-021: tapping the active day opens the day sheet', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'Sat 2' }))
expect(shellApi.sheet).toEqual({ id: 'day', payload: { dayId: 11 } })
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-022: a day tap in map mode fits without the list flag and draws the route', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
vi.mocked(planner.setRouteShown).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(12, false)
expect(planner.setRouteShown).toHaveBeenCalledWith(true)
})
// #1962 — the phone lost the desktop sidebar's collapse chevron, so the map showed
// every day's pins at once with no way to narrow it down.
it('FE-MOB-SHELL-040: entering the map focuses it on the selected day', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([11]))
})
it('FE-MOB-SHELL-041: a day tap in map mode moves the focus with it', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
vi.mocked(planner.setExpandedDayIds).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([12]))
})
it('FE-MOB-SHELL-042: a day tap in list mode leaves the map unfiltered', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'Sun 3' }))
expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
})
it('FE-MOB-SHELL-043: leaving the map clears the focus again', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
vi.mocked(planner.setExpandedDayIds).mockClear()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
})
it('FE-MOB-SHELL-023: the dock only shows enabled tabs and marks the active one', () => {
const { planner } = renderShell({
TRIP_TABS: [
{ id: 'plan', label: 'Plan' },
{ id: 'transports', label: 'Transport' },
// A plugin tab that arrived without a name still needs a label.
{ id: 'buchungen' },
],
} as Partial<TripPlanner>)
expect(screen.getByRole('button', { name: 'Plan' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('button', { name: 'buchungen' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Transport' }))
expect(planner.handleTabChange).toHaveBeenCalledWith('transports')
expect(shellApi.mode).toBe('go')
})
it('FE-MOB-SHELL-024: a dock tap changes the tab and drops out of browse mode', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
expect(shellApi.mode).toBe('browse')
fireEvent.click(screen.getByRole('button', { name: 'Lists' }))
expect(planner.handleTabChange).toHaveBeenCalledWith('listen')
expect(shellApi.mode).toBe('go')
})
it('FE-MOB-SHELL-025: the Mehr button opens the sheet and closeSheet clears it', () => {
renderShell()
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.more' }))
expect(shellApi.sheet).toEqual({ id: 'mehr', payload: undefined })
act(() => { shellApi.closeSheet() })
expect(shellApi.sheet).toBeNull()
})
it('FE-MOB-SHELL-026: a non-plan tab replaces the plan chrome with its panel', () => {
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
expect(screen.getByTestId('tab-panel')).toHaveTextContent('dateien')
expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'mobileTrip.travel' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'mobileTrip.mapView' })).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-027: the transports header opens a blank transport modal', () => {
const { planner } = renderShell({ activeTab: 'transports' } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'transport.addTransport' }))
expect(planner.setEditingTransport).toHaveBeenCalledWith(null)
expect(planner.setTransitPrefill).toHaveBeenCalledWith(null)
expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false)
expect(planner.setShowTransportModal).toHaveBeenCalledWith(true)
})
it('FE-MOB-SHELL-028: the transports header offers both importers when they are available', () => {
const { planner } = renderShell({ activeTab: 'transports', airTrailAvailable: true } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' }))
fireEvent.click(screen.getByRole('button', { name: 'reservations.airtrail.title' }))
expect(planner.setShowBookingImport).toHaveBeenCalledWith(true)
expect(planner.setShowAirTrailImport).toHaveBeenCalledWith(true)
})
it('FE-MOB-SHELL-029: the transports header hides the importers that are switched off', () => {
renderShell({
activeTab: 'transports', bookingImportAvailable: false, airTrailAvailable: false,
} as Partial<TripPlanner>)
expect(screen.queryByRole('button', { name: 'reservations.import.title' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'reservations.airtrail.title' })).not.toBeInTheDocument()
})
it('FE-MOB-SHELL-030: the transports compact toggle flips the shell flag', () => {
renderShell({ activeTab: 'transports' } as Partial<TripPlanner>)
const toggle = screen.getByRole('button', { name: 'mobileTrip.compactView' })
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-pressed', 'true')
expect(shellApi.transportsCompact).toBe(true)
expect(shellApi.bookingsCompact).toBe(false)
})
it('FE-MOB-SHELL-031: the bookings header opens a blank reservation and toggles its own density', () => {
const { planner } = renderShell({ activeTab: 'buchungen' } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.newReservation' }))
expect(planner.setEditingReservation).toHaveBeenCalledWith(null)
expect(planner.setShowReservationModal).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: 'reservations.import.title' }))
expect(planner.setShowBookingImport).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.compactView' }))
expect(shellApi.bookingsCompact).toBe(true)
expect(shellApi.transportsCompact).toBe(false)
})
it('FE-MOB-SHELL-032: the costs header raises the add-expense and CSV signals', () => {
renderShell({ activeTab: 'finanzplan' } as Partial<TripPlanner>)
expect(shellApi.addExpenseSignal).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'costs.addExpense' }))
fireEvent.click(screen.getByRole('button', { name: 'budget.exportCsv' }))
expect(shellApi.addExpenseSignal).toBe(1)
expect(shellApi.exportCostsCsvSignal).toBe(1)
})
it('FE-MOB-SHELL-033: the files header raises the upload and trash signals', () => {
renderShell({ activeTab: 'dateien' } as Partial<TripPlanner>)
fireEvent.click(screen.getByRole('button', { name: 'common.upload' }))
fireEvent.click(screen.getByRole('button', { name: 'files.trash' }))
fireEvent.click(screen.getByRole('button', { name: 'common.upload' }))
expect(shellApi.uploadFilesSignal).toBe(2)
expect(shellApi.openFilesTrashSignal).toBe(1)
})
it('FE-MOB-SHELL-034: the lists header shows packed/open counts and persists the sub-tab', () => {
const packingItems = [
{ id: 1, checked: true }, { id: 2, checked: false }, { id: 3, checked: true },
] as unknown as PackingItem[]
const todoItems = [{ id: 1, checked: false }, { id: 2, checked: false }] as unknown as TodoItem[]
renderShell({ activeTab: 'listen', packingItems, todoItems } as Partial<TripPlanner>)
expect(screen.getByRole('button', { name: /todo\.subtab\.packing/ })).toHaveTextContent('2/3')
const todoTab = screen.getByRole('button', { name: /todo\.subtab\.todo/ })
expect(todoTab).toHaveTextContent('mobileTrip.todoOpenCount:2')
fireEvent.click(todoTab)
expect(shellApi.listsTab).toBe('todo')
expect(sessionStorage.getItem('trip-lists-subtab-1')).toBe('todo')
})
it('FE-MOB-SHELL-035: the lists sub-tab is restored from the session', () => {
sessionStorage.setItem('trip-lists-subtab-1', 'todo')
renderShell({ activeTab: 'listen' } as Partial<TripPlanner>)
expect(shellApi.listsTab).toBe('todo')
})
it('FE-MOB-SHELL-036: the collab header switches the sub-tab', () => {
renderShell({ activeTab: 'collab' } as Partial<TripPlanner>)
expect(shellApi.collabTab).toBe('chat')
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.polls' }))
expect(shellApi.collabTab).toBe('polls')
fireEvent.click(screen.getByRole('button', { name: 'collab.tabs.notes' }))
expect(shellApi.collabTab).toBe('notes')
})
it('FE-MOB-SHELL-037: setTrTab from a slot routes through the planner and resets browse', () => {
const { planner } = renderShell()
fireEvent.click(screen.getByRole('button', { name: 'trip.mobilePlaces' }))
act(() => { shellApi.setTrTab('collab') })
expect(planner.handleTabChange).toHaveBeenCalledWith('collab')
expect(shellApi.mode).toBe('go')
})
it('FE-MOB-SHELL-038: openSheet carries the payload through to the slots', () => {
renderShell()
act(() => { shellApi.openSheet('note', { dayId: 12 }) })
expect(shellApi.sheet).toEqual({ id: 'note', payload: { dayId: 12 } })
})
})
describe('buildShell fixture', () => {
it('FE-MOB-SHELL-039: defaults to the plan/go state and takes overrides', () => {
const shell = buildShell({ mode: 'browse', browseFromEdit: true })
expect(shell.view).toBe('plan')
expect(shell.trTab).toBe('plan')
expect(shell.mode).toBe('browse')
expect(shell.browseFromEdit).toBe(true)
})
})