Files
TREK/client/tests/helpers/mobileTrip.ts
T
Maurice bafb74f872 feat(tours): put a multi-day tour on a day whole or a stage a day from it on
Putting a multi-day tour on a day through any of the usual ways put the
whole tour on that one day. Now the way it goes on is a choice: the whole
tour on this day, or stage 1 on this day and each next stage on the next
day of the trip.

The day menu of the tour lists and the detail's "Add to day" ask in the
same menu, as a second step after the day; a tour dragged onto a day, or
added from a place where no menu asked, gets a small dialog on the desktop
and a card on the phone. Only the trip's days are used: when fewer days
are left than stages, the menu says up to which stage fits and the rest
stay without a day. A stage already on a day moves to its new day, the
whole tour comes off the days it was on, and a stage the route no longer
has comes off too. The writes run in order through the store, the repo
and the mutation queue, so they work offline, and one undo takes the
spread back, the whole tour onto its day again.

The plan lives in stageSpread.ts, the writes and the question in
useTourDayChoice, which every way onto a day now passes through on both
the desktop and the phone.
2026-10-20 22:17:20 +02:00

356 lines
13 KiB
TypeScript

import { vi } from 'vitest';
import type { TripPlanner, MTripShellApi } from '../../src/mobile/screens/trip/MTripShell';
import type { Reservation } from '../../src/types';
import { changeTransitRouteWith, openTransportEditorWith } from '../../src/pages/tripPlanner/transportEditorOpeners';
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<string, ReturnType<typeof vi.fn>> {
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', 'updateReservationPositions', 'updateTodoItem', 'updateTrip', 'uploadPlaceImage',
];
const actions: Record<string, ReturnType<typeof vi.fn>> = {};
for (const name of names) actions[name] = vi.fn(async () => undefined);
return actions;
}
export function buildToast(): Record<string, ReturnType<typeof vi.fn>> {
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, string | number>): string =>
params ? `${key}:${Object.values(params).join(',')}` : key;
export function buildPlanner(overrides: Partial<TripPlanner> = {}): 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: [],
tours: [],
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<number>(),
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(),
tourDayQuestion: null,
answerTourDay: 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(),
// The planner's two ways into the transport editor, run on the setter spies above
// the way usePlannerDialogs runs them on its state.
openTransportEditor: vi.fn((r: Reservation) => openTransportEditorWith(planner, r)),
changeTransitRoute: vi.fn((r: Reservation) => changeTransitRouteWith(planner, r)),
selectedPlace: null,
dayOrderMap: new Map<number, number>(),
dayPlaces: [],
mapTileUrl: 'https://tile.example/{z}/{x}/{y}.png',
fontStyle: {},
splashDone: true,
};
const planner = { ...base, ...overrides } as unknown as TripPlanner;
return planner;
}
export function buildShell(overrides: Partial<MTripShellApi> = {}): 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 };
}