import { vi } from 'vitest'; import type { TripPlanner, MTripShellApi } from '../../src/mobile/screens/trip/MTripShell'; import { buildTrip } from './factories'; /** * Fixtures for the mobile trip screens. Every tab panel, sheet and overlay * under src/mobile/screens/trip receives the same two props — the planner * context from useTripPlanner() and the shell api from MTripShell — so tests * render them directly with these builders instead of mounting the whole shell. * * Defaults are the empty-but-valid state: a trip exists, every list is empty, * every callback is a spy. Pass overrides for the slice under test. */ /** Mirrors useTripStore's action surface as spies. */ export function buildTripActions(): Record> { const names = [ 'addCategory', 'addDayNote', 'addFile', 'addPackingContributor', 'addPackingItem', 'addPlace', 'addReservation', 'addTodoItem', 'appendDatedDay', 'assignPlaceToDay', 'clonePackingItem', 'deleteBudgetItem', 'deleteDayNote', 'deleteFile', 'deletePackingItem', 'deletePlace', 'deleteDay', 'deletePlacesMany', 'deleteReservation', 'deleteTodoItem', 'insertDay', 'loadBudgetItems', 'loadFiles', 'loadReservations', 'loadTrip', 'moveAssignment', 'moveDayNote', 'ratePlace', 'refreshDays', 'removeAssignment', 'removePackingContributor', 'reorderAssignments', 'reorderDays', 'setAssignments', 'setPackedCount', 'setPackingItemSharing', 'setSelectedDay', 'toggleBudgetMemberPaid', 'togglePackingItem', 'toggleReservationStatus', 'toggleTodoItem', 'updateDayNote', 'updateDayTitle', 'updatePackingItem', 'updatePlace', 'updatePlacesMany', 'updateReservation', 'updateTodoItem', 'updateTrip', 'uploadPlaceImage', ]; const actions: Record> = {}; for (const name of names) actions[name] = vi.fn(async () => undefined); return actions; } export function buildToast(): Record> { return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; } /** * `t` echoes the key so assertions stay independent of the English copy. * Pass a real translation fn through overrides when a test needs visible text. */ const echoT = (key: string, params?: Record): string => params ? `${key}:${Object.values(params).join(',')}` : key; export function buildPlanner(overrides: Partial = {}): TripPlanner { const trip = buildTrip({ id: 1, title: 'Japan 2026', currency: 'EUR' }); const base = { tripId: 1, navigate: vi.fn(), toast: buildToast(), t: echoT, language: 'en', settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km' }, placesPhotosEnabled: false, trip, days: [], places: [], assignments: [], // The unfiltered map the store holds. The place editor looks the visit it was // opened on up here, so every sheet test that edits a place needs the shape. storedAssignments: {}, packingItems: [], todoItems: [], categories: [], reservations: [], budgetItems: [], files: [], selectedDayId: null, isLoading: false, tripActions: buildTripActions(), can: vi.fn(() => true), canUploadFiles: true, isTourPlace: vi.fn(() => false), pushUndo: vi.fn(), undo: vi.fn(), canUndo: false, lastActionLabel: null, handleUndo: vi.fn(), enabledAddons: { packing: true, budget: true, documents: true, collab: true, vacay: true, atlas: true }, collabFeatures: { chat: true, notes: true, polls: true }, tripAccommodations: [], setTripAccommodations: vi.fn(), allowedFileTypes: ['image/jpeg', 'image/png', 'application/pdf'], tripMembers: [], setTripMembers: vi.fn(), refreshMembers: vi.fn(), loadAccommodations: vi.fn(), TRANSPORT_TYPES: new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']), TRIP_TABS: [], activeTab: 'plan', setActiveTab: vi.fn(), handleTabChange: vi.fn(), leftWidth: 320, rightWidth: 320, leftCollapsed: false, rightCollapsed: false, setLeftCollapsed: vi.fn(), setRightCollapsed: vi.fn(), startResizeLeft: vi.fn(), startResizeRight: vi.fn(), selectedPlaceId: null, selectedAssignmentId: null, setSelectedPlaceId: vi.fn(), selectAssignment: vi.fn(), showDayDetail: false, setShowDayDetail: vi.fn(), dayDetailCollapsed: false, setDayDetailCollapsed: vi.fn(), showPlaceForm: false, setShowPlaceForm: vi.fn(), editingPlace: null, setEditingPlace: vi.fn(), prefillCoords: null, setPrefillCoords: vi.fn(), placeFormDayId: null, setPlaceFormDayId: vi.fn(), reservationModalDayId: null, setReservationModalDayId: vi.fn(), editingAssignmentId: null, setEditingAssignmentId: vi.fn(), showTripForm: false, setShowTripForm: vi.fn(), showMembersModal: false, setShowMembersModal: vi.fn(), showReservationModal: false, setShowReservationModal: vi.fn(), editingReservation: null, setEditingReservation: vi.fn(), showBookingImport: false, setShowBookingImport: vi.fn(), bookingImportKind: 'bookings' as const, setBookingImportKind: vi.fn(), bookingImportAvailable: true, airTrailAvailable: false, showAirTrailImport: false, setShowAirTrailImport: vi.fn(), bookingForAssignmentId: null, setBookingForAssignmentId: vi.fn(), showTransportModal: false, setShowTransportModal: vi.fn(), editingTransport: null, setEditingTransport: vi.fn(), transportModalDayId: null, setTransportModalDayId: vi.fn(), transportModalAutomated: false, setTransportModalAutomated: vi.fn(), transitPrefill: null, setTransitPrefill: vi.fn(), transitJourney: null, setTransitJourney: vi.fn(), reservationPrefill: null, transportPrefill: null, importReviewActive: false, startImportReview: vi.fn(), advanceImportReview: vi.fn(), routeShown: false, setRouteShown: vi.fn(), autoShowRoute: vi.fn(), routeProfile: 'driving', setRouteProfile: vi.fn(), routeVias: [], // Whether the day ends at a stop, from the flag and from a manual boundary alike. The // helper answers from the flag; a case about boundaries overrides it. roadtripEndsDayAt: (stop: { endDay?: boolean } | null | undefined) => !!stop?.endDay, fitKey: 0, setFitKey: vi.fn(), mobileSidebarOpen: false, setMobileSidebarOpen: vi.fn(), mobilePlanScrollTopRef: { current: 0 }, mobilePlacesScrollTopRef: { current: 0 }, deletePlaceId: null, setDeletePlaceId: vi.fn(), deletePlaceIds: null, setDeletePlaceIds: vi.fn(), // Adding a day from the days sheet: a trip without dates, so the single button. dayAdd: { nextDate: null, blocked: null, datedBlocked: null, busy: false, onAddDated: vi.fn() }, // Deleting a day from the days sheet: no question open, nothing blocking. deleteDayId: null, setDeleteDayId: vi.fn(), deleteDayTitle: '', deleteDayLines: [], deleteDayBlocked: null, handleDeleteDay: vi.fn(), confirmDeleteDay: vi.fn(async () => undefined), // resolveVisibleConnectionIds returns an array, and consumers call .includes(). visibleConnections: [] as number[], roadtripConnections: [] as number[], toggleConnection: vi.fn(), allConnectionsShown: false, toggleAllConnections: vi.fn(), mapTransportDetail: null, setMapTransportDetail: vi.fn(), isMobile: true, isTouch: true, expandedDayIds: new Set(), setExpandedDayIds: vi.fn(), mapPlaces: [], // The recorded-route overlay (#2279): off and empty. MMapArea reads // `dawarichTrail.track` unconditionally, so the shape has to be here even // when the addon is not in play. dawarichEnabled: false, dawarichTrailShown: false, toggleDawarichTrail: vi.fn(), dawarichTrail: { track: null, status: 'idle', reload: vi.fn() }, // The road trip addon's routing round. The shell reads days/totalDistance for // its stage header on every render, addon on or off, so the empty-but-valid // shape belongs in the base rather than in the overrides of one suite. roadtripRoutes: { days: [], quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [], totalDistance: 0, totalDuration: 0, totalStops: 0, loading: false, }, // The corridor search, idle. Both the search bar over the stage and the search // sheet read it on every render of the road trip tab, addon on or off, so the // empty-but-valid shape belongs here rather than in one suite's overrides. roadtripCorridor: { dayId: '', setDayId: vi.fn(), day: undefined, categories: ['fuel'], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: { results: [], progress: { done: 0, total: 0 }, loading: false, capped: false, failedSources: [], failedAreas: 0, truncatedAreas: 0, error: false, spine: [], search: vi.fn(), clear: vi.fn(), }, nameFilter: '', setNameFilter: vi.fn(), anchors: [], section: null, setSection: vi.fn(), sectionKm: 50, setSectionKm: vi.fn(), socketFilter: '', setSocketFilter: vi.fn(), minKw: 0, setMinKw: vi.fn(), visible: [], insertIndexFor: vi.fn(() => 0), stopsAlongKm: [], clear: vi.fn(), }, // The fuel search, idle: the dry band in the chain reads it per leg. refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn(), }, askRefuel: vi.fn(), acceptRefuel: vi.fn(), // Other ways of driving a leg, with no picker open. The road trip tab and the map area // read the picker on every render, so the closed shape belongs in the base. routeAlternatives: { open: null, ask: vi.fn(), close: vi.fn() }, askRouteAlternatives: vi.fn(), chooseRouteAlternative: vi.fn(async () => undefined), alternativeOverlays: [], alternativeFocusPoints: [], highlightedAlternative: null, setHighlightedAlternative: vi.fn(), // The drive's vias, online and empty. `editable` is what the leg buttons read. roadtripVias: { byDay: {}, trackByDay: {}, stale: false, editable: true, add: vi.fn(), addMany: vi.fn(), move: vi.fn(), remove: vi.fn(), reanchor: vi.fn(), }, mapFocusPoints: [], focusRoadtripPoint: vi.fn(), handlePoiClick: vi.fn(), route: null, routeSegments: [], routeInfo: null, setRoute: vi.fn(), setRouteInfo: vi.fn(), updateRouteForDay: vi.fn(), handleSelectDay: vi.fn(), handlePlaceClick: vi.fn(), handleMarkerClick: vi.fn(), handleMapClick: vi.fn(), handleMapContextMenu: vi.fn(), openAddPlaceFromPoi: vi.fn(), handleSavePlace: vi.fn(), openPlaceEditor: vi.fn(), handleDeletePlace: vi.fn(), confirmDeletePlace: vi.fn(), confirmDeletePlaces: vi.fn(), confirmChangeCategory: vi.fn(), handleAssignToDay: vi.fn(), handleRemoveAssignment: vi.fn(), handleReorder: vi.fn(), handleReorderDays: vi.fn(), handleAddDay: vi.fn(), handleUpdateDayTitle: vi.fn(), handleSaveReservation: vi.fn(), handleSaveTransport: vi.fn(), handleDeleteReservation: vi.fn(), selectedPlace: null, dayOrderMap: new Map(), dayPlaces: [], mapTileUrl: 'https://tile.example/{z}/{x}/{y}.png', fontStyle: {}, splashDone: true, }; return { ...base, ...overrides } as unknown as TripPlanner; } export function buildShell(overrides: Partial = {}): MTripShellApi { const base: MTripShellApi = { view: 'plan', rtView: 'list', mapFront: false, toggleRtView: vi.fn(), rtReach: 'ahead', setRtReach: vi.fn(), mode: 'go', trTab: 'plan', setTrTab: vi.fn(), setTravelMode: vi.fn(), toggleView: vi.fn(), browseFromEdit: false, sheet: null, openSheet: vi.fn(), closeSheet: vi.fn(), listsTab: 'packing', setListsTab: vi.fn(), collabTab: 'chat', setCollabTab: vi.fn(), transportsCompact: false, bookingsCompact: false, addExpenseSignal: 0, exportCostsCsvSignal: 0, uploadFilesSignal: 0, openFilesTrashSignal: 0, openDocSyncSignal: 0, }; return { ...base, ...overrides }; }