import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { server } from '../../../helpers/msw/server' import { seedStore } from '../../../helpers/store' import { buildUser } from '../../../helpers/factories' import { useAuthStore } from '../../../../src/store/authStore' import { useDocSyncOfferStore } from '../../../../src/store/docSyncOfferStore' 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-068 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
} } /** * One identity per slot, created once. Built inside the wrapper's render body instead, * every rerender hands the shell four brand-new component types and React remounts all * of them, which would quietly mask the one thing FE-MOB-SHELL-054 is watching for. */ const PlanTimelineSlot = slot('plan-timeline') const MapAreaSlot = slot('map-area') const PlacesBrowserSlot = slot('places-browser') const SheetsSlot = slot('sheets') function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) { shellApi = shell return
{tab}
} 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[] /** * The same sections with the road trip addon on, in the order the planner hook builds * them, which is NOT the dock's order. The dock seats by its own priority list, so a * fixture that mirrors the hook is the only one that can tell the two apart. */ const RT_TABS = [ { id: 'plan', label: 'Plan' }, { id: 'transports', label: 'Transport' }, { id: 'buchungen', label: 'Bookings' }, { id: 'roadtrip', label: 'Road trip' }, { id: 'listen', label: 'Lists' }, { id: 'finanzplan', label: 'Budget' }, { id: 'dateien', label: 'Files' }, { id: 'collab', label: 'Collaboration' }, ] /** One routed stage, as the addon reports it. */ const stage = (over: Record = {}) => ({ dayId: 11, dayNumber: 1, date: '2026-05-02', title: null, distance: 123000, duration: 5400, stops: [], legs: [], legVias: [], geometry: [], schedule: { entries: [] }, driveWarnings: [], ...over, }) /** The dock's seats, in order, plus the More button at the end. */ const dockLabels = () => within(screen.getByRole('navigation')).getAllByRole('button').map(b => b.getAttribute('aria-label')) 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 = {}) { mocks.planner = buildPlanner({ days: DAYS, selectedDayId: 11, TRIP_TABS, ...overrides, } as Partial) const Shell = () => ( ) const view = render() return { ...view, planner: mocks.planner, rerenderShell: () => view.rerender() } } const spy = (planner: TripPlanner, name: keyof TripPlanner) => vi.mocked(planner[name] as unknown as ReturnType) describe('MTripShell', () => { beforeEach(() => { sessionStorage.clear() // Document sync as a fresh install has it: every provider off, nothing bound. useDocSyncOfferStore.setState({ bound: {}, providers: null }) seedStore(useAuthStore, { user: null, isAuthenticated: false }) server.use( http.get('/api/trips/:tripId/docsync/providers', () => HttpResponse.json([])), http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([])), ) }) it('FE-MOB-SHELL-001: shows the loading splash with the trip title while the planner loads', () => { renderShell({ isLoading: true } as Partial) 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) 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) 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) 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) 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) expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11) }) it('FE-MOB-SHELL-006: leaves an existing day selection alone, and a later deselect too', () => { const { planner, rerenderShell } = renderShell() expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() // Clearing the day is the user's doing; seeding it again here would be the // shell fighting them for it. planner.selectedDayId = null rerenderShell() expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() }) it('FE-MOB-SHELL-006b: seeds the next dated day when today falls in a gap', () => { const now = new Date() const iso = (offset: number) => { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } const days = [ { id: 11, day_number: 1, date: iso(-2) }, { id: 12, day_number: 2, date: iso(2) }, ] as unknown as Day[] const { planner } = renderShell({ days, selectedDayId: null } as Partial) expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12) }) it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => { const { planner } = renderShell({ days: [], selectedDayId: null } as Partial) expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() expect(screen.queryByRole('button', { name: 'Sat 2' })).not.toBeInTheDocument() }) it('FE-MOB-SHELL-007b: re-seeds after re-entering the same trip from the dashboard', () => { const { planner, rerenderShell } = renderShell() expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() // The dashboard leaves the previous trip snapshot in the store. loadTrip // clears that selection while it reloads the same trip. planner.isLoading = true planner.days = [] planner.selectedDayId = null rerenderShell() planner.isLoading = false planner.days = DAYS rerenderShell() expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11) }) 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) 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.autoShowRoute).toHaveBeenCalled() 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() // Entering the map defaults the route on once; leaving it leaves the choice alone. expect(spy(planner, 'autoShowRoute')).toHaveBeenCalledTimes(1) expect(spy(planner, 'setRouteShown')).not.toHaveBeenCalled() }) it('FE-MOB-SHELL-016: the map toggle skips the refit while no day is selected', () => { const { planner } = renderShell({ selectedDayId: null } as Partial) fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' })) expect(planner.autoShowRoute).toHaveBeenCalled() 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) 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.autoShowRoute).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.autoShowRoute).toHaveBeenCalled() }) // #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) 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) 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) 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) 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) 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) 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) 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) 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) 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-067: the files header leaves the sync button out while no provider is on and nothing is bound', async () => { seedStore(useAuthStore, { user: buildUser({ id: 1, role: 'user' }), isAuthenticated: true }) const asked: string[] = [] server.use( http.get('/api/trips/:tripId/docsync/links', () => { asked.push('links') return HttpResponse.json([]) }), ) renderShell({ activeTab: 'dateien' } as Partial) await waitFor(() => expect(asked).toContain('links')) expect(screen.queryByRole('button', { name: 'docsync.title' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.upload' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'files.trash' })).toBeInTheDocument() }) it('FE-MOB-SHELL-068: a bound trip shows the sync button to a member, and it raises the open signal', async () => { seedStore(useAuthStore, { user: buildUser({ id: 999, role: 'user' }), isAuthenticated: true }) server.use(http.get('/api/trips/:tripId/docsync/links', () => HttpResponse.json([{ id: 1, providerId: 'paperless' }]))) renderShell({ activeTab: 'dateien' } as Partial) fireEvent.click(await screen.findByRole('button', { name: 'docsync.title' })) expect(shellApi.openDocSyncSignal).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) 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) expect(shellApi.listsTab).toBe('todo') }) it('FE-MOB-SHELL-036: the collab header switches the sub-tab', () => { renderShell({ activeTab: 'collab' } as Partial) 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 } }) }) // The chip rail overflows from roughly six days on, and swiping the day panel // (#2051) can move the day well past what is on screen. describe('chip rail auto-scroll', () => { const RAIL = { left: 0, right: 300 } as DOMRect /** Rects are all zero without layout, so the two boxes are stubbed by role. */ function stubRects(chip: Partial) { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { return (this.tagName === 'BUTTON' ? { ...RAIL, ...chip } : RAIL) as DOMRect }) } afterEach(() => { vi.restoreAllMocks() }) it('FE-MOB-SHELL-044: a clipped active chip pulls itself into the middle of the rail', () => { const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView) scrollIntoView.mockClear() stubRects({ left: 480, right: 540 }) renderShell({ selectedDayId: 12 } as Partial) expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', inline: 'center', block: 'nearest' }) }) it('FE-MOB-SHELL-045: a chip already in view never shifts the rail under the thumb', () => { const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView) scrollIntoView.mockClear() stubRects({ left: 40, right: 100 }) renderShell({ selectedDayId: 12 } as Partial) expect(scrollIntoView).not.toHaveBeenCalled() }) it('FE-MOB-SHELL-046: reduced motion jumps instead of gliding', () => { const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView) scrollIntoView.mockClear() stubRects({ left: 480, right: 540 }) vi.mocked(window.matchMedia).mockImplementation((query: string) => ({ matches: query.includes('reduced-motion'), media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }) as unknown as MediaQueryList) renderShell({ selectedDayId: 12 } as Partial) expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' }) }) }) // #2392 — on the road the phone plan is used one day at a time, and finding today // again meant scrolling the rail; today is marked and one tap away. describe('today on the day rail (#2392)', () => { const shift = (days: number) => { const d = new Date() d.setDate(d.getDate() + days) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } const running = () => [ { id: 21, day_number: 1, date: shift(-1) }, { id: 22, day_number: 2, date: todayIso() }, { id: 23, day_number: 3, date: shift(1) }, ] as unknown as Day[] const jump = () => screen.getByRole('button', { name: 'mobileTrip.jumpToToday' }) it('FE-MOB-SHELL-090: marks today and jumps back to it from another day', () => { const { planner, container } = renderShell({ days: running(), selectedDayId: 21 } as Partial) const marked = container.querySelectorAll('[data-today]') expect(marked).toHaveLength(1) expect(marked[0].className).toContain('ring-inset') expect(marked[0]).toHaveTextContent('mobileTrip.today') expect(jump()).toBeEnabled() fireEvent.click(jump()) expect(planner.handleSelectDay).toHaveBeenLastCalledWith(22, true) }) it('FE-MOB-SHELL-091: with today open the button stays, dimmed, so the rail does not move', () => { const { container } = renderShell({ days: running(), selectedDayId: 22 } as Partial) expect(jump()).toBeDisabled() // The active chip keeps its own look; the dot still says which day is today. expect(container.querySelector('[data-today]')?.className).not.toContain('ring-inset') }) it('FE-MOB-SHELL-092: outside the trip dates there is no today and no button', () => { const { container } = renderShell() expect(container.querySelector('[data-today]')).toBeNull() expect(screen.queryByRole('button', { name: 'mobileTrip.jumpToToday' })).toBeNull() }) }) // #2257 — the map filters down to one day, and until now nothing took that // filter back off: the chip rail can only swap one day for another, and the // second tap on the active chip is the day sheet. Its own control, map only. describe('the all-days toggle (#2257)', () => { const allDays = () => screen.getByRole('button', { name: 'mobileTrip.allDays' }) const enterMap = () => fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' })) it('FE-MOB-SHELL-047: stays off the plan view, where nothing is filtered anyway', () => { renderShell() expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument() enterMap() expect(allDays()).toBeInTheDocument() }) it('FE-MOB-SHELL-048: drops the day and its pin filter so the whole trip comes back', () => { const { planner } = renderShell() enterMap() vi.mocked(planner.setExpandedDayIds).mockClear() fireEvent.click(allDays()) expect(planner.handleSelectDay).toHaveBeenLastCalledWith(null, false) expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null) // The day sheet belongs to the chip, not to this button. expect(shellApi.sheet).toBeNull() }) it('FE-MOB-SHELL-049: a second press goes back to the day it came from', () => { const { planner, rerenderShell } = renderShell() enterMap() fireEvent.click(allDays()) // The planner is a fixture, so the commit is replayed by hand. ;(planner as { selectedDayId: number | null }).selectedDayId = null rerenderShell() vi.mocked(planner.setExpandedDayIds).mockClear() vi.mocked(planner.autoShowRoute).mockClear() fireEvent.click(allDays()) expect(planner.handleSelectDay).toHaveBeenLastCalledWith(11, false) expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([11])) expect(planner.autoShowRoute).toHaveBeenCalled() }) it('FE-MOB-SHELL-050: leaving the map puts a day back, the timeline has no all-days state', () => { const { planner, rerenderShell } = renderShell() enterMap() fireEvent.click(allDays()) ;(planner as { selectedDayId: number | null }).selectedDayId = null rerenderShell() fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' })) expect(planner.handleSelectDay).toHaveBeenLastCalledWith(11, true) }) it('FE-MOB-SHELL-051: the active chip still opens the day sheet on the map', () => { const { planner } = renderShell() enterMap() vi.mocked(planner.handleSelectDay).mockClear() fireEvent.click(screen.getByRole('button', { name: 'Sat 2' })) expect(shellApi.sheet).toEqual({ id: 'day', payload: { dayId: 11 } }) expect(planner.handleSelectDay).not.toHaveBeenCalled() }) }) // The road trip tab. The drive is a second reading of the same trip on the same map, // so it does not get a map of its own: the shell's single instance now lies under // BOTH tabs, and everything floating over it keys off `mapFront` rather than off // `view`, which only ever meant the plan tab. describe('the road trip tab', () => { /** Commit a tab change the way the planner would, and redraw. */ function goToTab(planner: TripPlanner, rerenderShell: () => void, tabId: string) { ;(planner as { activeTab: string }).activeTab = tabId rerenderShell() } const rtSwitch = () => screen.getByRole('button', { name: 'mobileTrip.mapView' }) const backToList = () => screen.getByRole('button', { name: 'mobileTrip.listView' }) it('FE-MOB-SHELL-052: the drive takes the second dock seat and the packing list loses its own', () => { renderShell({ TRIP_TABS: RT_TABS } as Partial) // Second seat, not fourth: the dock orders by its own priority list, so the drive // sits beside the plan however late the hook appends it. expect(dockLabels()).toEqual(['Plan', 'Road trip', 'Transport', 'Bookings', 'Budget', 'mobileTrip.more']) // Six sections would leave 3px between the circles, so the cap cuts the last one // and the More sheet picks it up. expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument() }) it('FE-MOB-SHELL-053: without the addon the dock is exactly what it was', () => { renderShell() expect(dockLabels()).toEqual(['Plan', 'Transport', 'Bookings', 'Budget', 'Lists', 'mobileTrip.more']) }) it('FE-MOB-SHELL-054: the map survives the move between the plan tab and the drive', () => { const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial) const map = screen.getByTestId('map-area') goToTab(planner, rerenderShell, 'roadtrip') expect(screen.getByTestId('tab-panel')).toHaveTextContent('roadtrip') // Identity, not presence. Written as two JSX positions the map remounts here, // which tears down the WebGL context and reloads every tile, a flash on the // device that a presence check would happily call a pass. expect(screen.getByTestId('map-area')).toBe(map) goToTab(planner, rerenderShell, 'plan') expect(screen.getByTestId('map-area')).toBe(map) expect(screen.getByTestId('plan-timeline')).toBeInTheDocument() }) it('FE-MOB-SHELL-055: a tab with no map still tears it down', () => { const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial) expect(screen.getByTestId('map-area')).toBeInTheDocument() goToTab(planner, rerenderShell, 'transports') expect(screen.queryByTestId('map-area')).not.toBeInTheDocument() expect(screen.getByTestId('tab-panel')).toHaveTextContent('transports') }) it('FE-MOB-SHELL-056: the switch in the drive flips its own half, not the plan view', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial) expect(shellApi.mapFront).toBe(false) fireEvent.click(rtSwitch()) expect(shellApi.rtView).toBe('map') expect(shellApi.mapFront).toBe(true) expect(shellApi.view).toBe('plan') fireEvent.click(backToList()) expect(shellApi.rtView).toBe('list') expect(shellApi.mapFront).toBe(false) }) it('FE-MOB-SHELL-057: the drive switch draws the route and otherwise leaves the camera alone', () => { const { planner } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', selectedDayId: null, } as Partial) fireEvent.click(rtSwitch()) expect(planner.autoShowRoute).toHaveBeenCalledTimes(1) // Deliberately quieter than the plan tab's toggle: the stage is always one day, // both halves show the same one, and the camera belongs to whoever looked last. expect(planner.handleSelectDay).not.toHaveBeenCalled() expect(planner.setExpandedDayIds).not.toHaveBeenCalled() fireEvent.click(backToList()) // And no day restore on the way out, which is what the plan tab does here. expect(planner.handleSelectDay).not.toHaveBeenCalled() expect(planner.setExpandedDayIds).not.toHaveBeenCalled() }) it('FE-MOB-SHELL-058: the two halves are separate states, so neither tab drags the other into its own', () => { const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial) fireEvent.click(rtSwitch()) expect(shellApi.view).toBe('map') expect(shellApi.rtView).toBe('list') expect(shellApi.mapFront).toBe(true) goToTab(planner, rerenderShell, 'roadtrip') // The plan tab is still on its map; the drive opens on its chain regardless. expect(shellApi.view).toBe('map') expect(shellApi.mapFront).toBe(false) expect(screen.getByRole('button', { name: 'mobileTrip.mapView' })).toBeInTheDocument() }) it('FE-MOB-SHELL-059: the all-days button follows the map into the drive', () => { const { planner } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial) expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument() fireEvent.click(rtSwitch()) vi.mocked(planner.setExpandedDayIds).mockClear() fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.allDays' })) // The whole drive in its day colours, off the same handler the plan map uses. expect(planner.handleSelectDay).toHaveBeenLastCalledWith(null, false) expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null) }) it('FE-MOB-SHELL-060: the stage header opens the driving settings', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial) fireEvent.click(screen.getByRole('button', { name: 'roadtrip.title' })) expect(shellApi.sheet).toEqual({ id: 'rtinfo', payload: undefined }) }) it('FE-MOB-SHELL-061: the stage header names the day on screen and what it costs', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', roadtripRoutes: { days: [stage()], totalDistance: 240000 }, } as unknown as Partial) // The stage beats the total: 123 km is today's drive, 240 km is the whole trip. const header = screen.getByRole('button', { name: 'Sat 2, 123 km' }) // Two badges rather than one line joined with a dot. expect(within(header).getByText('Sat 2')).not.toBe(within(header).getByText('123 km')) expect(header.textContent).not.toContain('·') }) it('FE-MOB-SHELL-062: with the day filter off it reads the whole drive', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', selectedDayId: null, roadtripRoutes: { days: [stage()], totalDistance: 240000 }, } as unknown as Partial) const header = screen.getByRole('button', { name: '240 km' }) // The total alone: without a stage there is no day to badge. const badges = header.querySelectorAll('span.rounded-full') expect(badges).toHaveLength(1) expect(badges[0]).toHaveTextContent('240 km') }) it('FE-MOB-SHELL-063: before anything has routed it falls back to the addon name, and the plan tab never shows it', () => { const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial) // Never an empty figure: the header is the only way into the driving settings. expect(screen.getByRole('button', { name: 'roadtrip.title' })).toBeInTheDocument() goToTab(planner, rerenderShell, 'plan') expect(screen.queryByRole('button', { name: 'roadtrip.title' })).not.toBeInTheDocument() }) it('FE-MOB-SHELL-064: a stage whose day the rail has lost still reads as a day number', () => { // A remote day:deleted can retire the day while its legs are still on screen. renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', selectedDayId: 99, roadtripRoutes: { days: [stage({ dayId: 99, dayNumber: 4, distance: 50000 })], totalDistance: 240000 }, } as unknown as Partial) expect(screen.getByRole('button', { name: 'planner.dayN:4, 50 km' })).toBeInTheDocument() }) it('FE-MOB-SHELL-065: a stage the round has not measured yet shows its day badge alone', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', roadtripRoutes: { days: [stage({ distance: 0 })], totalDistance: 240000 }, } as unknown as Partial) // Not "Sat 2 · 0 m", and not the whole trip's 240 km standing in for the day either. // The active day chip below shares the name, so the header is the one with the sliders. const header = screen.getAllByRole('button', { name: 'Sat 2' }) .find(button => button.querySelector('.lucide-sliders-horizontal')) as HTMLElement expect(header).toBeDefined() expect(within(header).queryByText('0 m')).toBeNull() expect(within(header).queryByText('240 km')).toBeNull() }) it('FE-MOB-SHELL-066: the header figures are badges, the day in caps and the distance in its own case', () => { renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', roadtripRoutes: { days: [stage()], totalDistance: 240000 }, } as unknown as Partial) const header = screen.getByRole('button', { name: 'Sat 2, 123 km' }) const badges = Array.from(header.children).filter(el => el.tagName === 'SPAN') expect(badges.map(b => b.textContent)).toEqual(['Sat 2', '123 km']) for (const badge of badges) expect(badge.className).toContain('rounded-full') expect(badges[0].className).toContain('uppercase') // m and M are different units, so the distance keeps its case. expect(badges[1].className).not.toContain('uppercase') // Neutral, both: the active day chip right below is already the filled pill. expect(badges[0].className).not.toContain('bg-m-act') }) }) }) 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) }) })